diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c64a27597..9adcdf5f1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -115,6 +115,8 @@ ### Added +- Added plain-RPC slash command discovery with command source metadata and startup/update notifications ([#2261](https://github.com/can1357/oh-my-pi/issues/2261)). + - Added RPC subagent subscription frames, snapshots, and transcript catch-up APIs for desktop clients embedding `omp --mode rpc`. - Added opt-in `shellMinimizer.sourceOutlineLevel` and `shellMinimizer.legacyFilters` settings so shell minimization can tune source outlining and selectively fall back to conservative legacy routing. - Added repeatable `--config ` CLI overlays for temporary `config.yml`-style settings without editing the persistent global config ([#1733](https://github.com/can1357/oh-my-pi/issues/1733)). diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 5f07d0ff3..ca0794376 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -56,7 +56,7 @@ import { } from "../../extensibility/extensions"; import { runExtensionCompact } from "../../extensibility/extensions/compact-handler"; import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; -import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills"; +import { buildSkillPromptMessage } from "../../extensibility/skills"; import { loadSlashCommands } from "../../extensibility/slash-commands"; import { resolveLocalUrlToPath } from "../../internal-urls"; import { MCPManager } from "../../mcp/manager"; @@ -71,12 +71,8 @@ import { type SessionInfo as StoredSessionInfo, type UsageStatistics, } from "../../session/session-manager"; -import { - ACP_BUILTIN_RESERVED_NAMES, - ACP_BUILTIN_SLASH_COMMANDS, - executeAcpBuiltinSlashCommand, - isAcpBuiltinShadowedName, -} from "../../slash-commands/acp-builtins"; +import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; +import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands"; import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking"; import { normalizeLocalScheme } from "../../tools/path-utils"; import { runResolveInvocation } from "../../tools/resolve"; @@ -1662,66 +1658,7 @@ export class AcpAgent implements Agent { } async #buildAvailableCommands(session: AgentSession): Promise { - const commands: AvailableCommand[] = []; - const seenNames = new Set(); - const appendCommand = (command: AvailableCommand): void => { - if (seenNames.has(command.name)) { - return; - } - seenNames.add(command.name); - commands.push(command); - }; - - // Advertise in the order dispatch resolves them (mirrors AgentSession - // dispatch: builtins → skills → extensions → custom TS → file-based). - // `appendCommand` dedupes by name so earlier entries win; extension - // commands therefore correctly shadow custom TS commands of the same - // name, matching the runtime behaviour of #tryExecuteExtensionCommand - // running before #tryExecuteCustomCommand. - for (const command of ACP_BUILTIN_SLASH_COMMANDS) { - appendCommand(command); - } - - if (session.skillsSettings?.enableSkillCommands) { - for (const skill of session.skills) { - appendCommand({ - name: getSkillSlashCommandName(skill), - description: skill.description || `Run ${skill.name} skill`, - input: { hint: "arguments" }, - }); - } - } - - for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) { - // Reserved-set filtering in getRegisteredCommands only covers exact - // names; colon-namespaced names whose prefix is a builtin (e.g. - // `model:foo`) would still dispatch to the builtin in ACP. - if (isAcpBuiltinShadowedName(command.name)) { - continue; - } - appendCommand({ - name: command.name, - description: command.description ?? "(extension command)", - input: { hint: "arguments" }, - }); - } - - for (const command of session.customCommands) { - appendCommand({ - name: command.command.name, - description: command.command.description, - input: { hint: "arguments" }, - }); - } - - for (const command of await loadSlashCommands({ cwd: session.sessionManager.getCwd() })) { - appendCommand({ - name: command.name, - description: command.description, - }); - } - - return commands; + return toAcpAvailableCommands(await buildAvailableSlashCommands(session)); } #toSessionInfo(session: StoredSessionInfo): SessionInfo { diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index e847ec735..9ac6a3bb3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -13,6 +13,8 @@ import type { FileSink } from "bun"; import type { BashResult } from "../../exec/bash-executor"; import type { AgentSessionEvent, SessionStats } from "../../session/agent-session"; import type { + RpcAvailableCommandsUpdateFrame, + RpcAvailableSlashCommand, RpcCommand, RpcExtensionUIRequest, RpcHandoffResult, @@ -63,6 +65,7 @@ export type RpcSessionEventListener = (event: AgentSessionEvent) => void; export type RpcSubagentLifecycleListener = (payload: RpcSubagentLifecycleFrame["payload"]) => void; export type RpcSubagentProgressListener = (payload: RpcSubagentProgressFrame["payload"]) => void; export type RpcSubagentEventListener = (payload: RpcSubagentEventFrame["payload"]) => void; +export type RpcAvailableCommandsUpdateListener = (commands: RpcAvailableSlashCommand[]) => void; export interface RpcClientToolContext { toolCallId: string; @@ -161,6 +164,11 @@ function isRpcSubagentEventFrame(value: unknown): value is RpcSubagentEventFrame return value.type === "subagent_event" && isRecord(value.payload); } +function isRpcAvailableCommandsUpdateFrame(value: unknown): value is RpcAvailableCommandsUpdateFrame { + if (!isRecord(value)) return false; + return value.type === "available_commands_update" && Array.isArray(value.commands); +} + function isRpcHostToolCallRequest(value: unknown): value is RpcHostToolCallRequest { if (!isRecord(value)) return false; return ( @@ -202,6 +210,7 @@ export class RpcClient { #subagentLifecycleListeners = new Set(); #subagentProgressListeners = new Set(); #subagentEventListeners = new Set(); + #availableCommandsUpdateListeners = new Set(); #pendingRequests: Map void; reject: (error: Error) => void }> = new Map(); #customTools: RpcClientCustomTool[] = []; @@ -377,6 +386,14 @@ export class RpcClient { return () => this.#subagentEventListeners.delete(listener); } + /** + * Subscribe to slash-command availability updates emitted by the RPC server. + */ + onAvailableCommandsUpdate(listener: RpcAvailableCommandsUpdateListener): () => void { + this.#availableCommandsUpdateListeners.add(listener); + return () => this.#availableCommandsUpdateListeners.delete(listener); + } + /** * Get collected stderr output (useful for debugging). */ @@ -511,6 +528,14 @@ export class RpcClient { return this.#getData<{ models: ModelInfo[] }>(response).models; } + /** + * Get list of available slash commands. + */ + async getAvailableCommands(): Promise { + const response = await this.#send({ type: "get_available_commands" }); + return this.#getData<{ commands: RpcAvailableSlashCommand[] }>(response).commands; + } + /** * Set thinking level. */ @@ -825,6 +850,13 @@ export class RpcClient { return; } + if (isRpcAvailableCommandsUpdateFrame(data)) { + for (const listener of this.#availableCommandsUpdateListeners) { + listener(data.commands); + } + return; + } + if (!isAgentSessionEvent(data)) return; for (const listener of this.#sessionEventListeners) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index ceeb809cf..77661797b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -12,6 +12,8 @@ */ import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils"; +import { reset as resetCapabilities } from "../../capability"; +import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import { type ExtensionUIContext, type ExtensionUIDialogOptions, @@ -19,8 +21,13 @@ import { type ExtensionWidgetOptions, getExtensionUISelectOptionLabel, } from "../../extensibility/extensions"; +import { buildSkillPromptMessage } from "../../extensibility/skills"; +import { loadSlashCommands } from "../../extensibility/slash-commands"; import { type Theme, theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; +import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages"; +import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; +import { buildAvailableSlashCommands } from "../../slash-commands/available-commands"; import type { EventBus } from "../../utils/event-bus"; import { initializeExtensions } from "../runtime-init"; import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools"; @@ -70,6 +77,28 @@ export type RpcSessionChangeResult = | { type: "branch"; data: { text: string; cancelled: boolean } }; export type RpcSessionChangeSession = Pick; + +export type RpcSkillCommandSession = Pick; + +export async function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string): Promise { + if (!text.startsWith("/skill:")) return false; + if (!session.skillsSettings?.enableSkillCommands) return false; + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); + const skillName = commandName.slice("skill:".length); + const skill = session.skills.find(candidate => candidate.name === skillName); + if (!skill) return false; + const built = await buildSkillPromptMessage(skill, args); + await session.promptCustomMessage({ + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: built.message, + display: true, + details: built.details, + attribution: "user", + }); + return true; +} export type RpcSubagentResetRegistry = Pick; export async function handleRpcSessionChange( @@ -511,6 +540,24 @@ export async function runRpcMode( output(event); }); + const getAvailableCommands = async () => buildAvailableSlashCommands(session); + const reloadPluginState = async () => { + const cwd = session.sessionManager.getCwd(); + const projectPath = await resolveActiveProjectRegistryPath(cwd); + clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + resetCapabilities(); + session.setSlashCommands(await loadSlashCommands({ cwd })); + await session.refreshSshTool({ activateIfAvailable: true }); + await emitAvailableCommandsUpdate(); + }; + const emitAvailableCommandsUpdate = async () => { + output({ type: "available_commands_update", commands: await getAvailableCommands() }); + }; + session.subscribeCommandMetadataChanged(() => { + void emitAvailableCommandsUpdate(); + }); + await emitAvailableCommandsUpdate(); + // Handle a single command const handleCommand = async (command: RpcCommand): Promise => { const id = command.id; @@ -521,6 +568,33 @@ export async function runRpcMode( // ================================================================= case "prompt": { + if (await tryRunRpcSkillCommand(session, command.message)) { + return success(id, "prompt"); + } + const builtinResult = await executeAcpBuiltinSlashCommand(command.message, { + session, + sessionManager: session.sessionManager, + settings: session.settings, + cwd: session.sessionManager.getCwd(), + output: text => output({ type: "command_output", text }), + refreshCommands: emitAvailableCommandsUpdate, + reloadPlugins: reloadPluginState, + notifyTitleChanged: async () => { + output({ type: "session_info_update", title: session.sessionName, sessionId: session.sessionId }); + }, + notifyConfigChanged: async () => { + output({ type: "config_update", model: session.model, thinkingLevel: session.thinkingLevel }); + }, + }); + if (builtinResult !== false) { + if ("prompt" in builtinResult) { + session + .prompt(builtinResult.prompt, { images: command.images }) + .catch(e => output(error(id, "prompt", e.message))); + } + return success(id, "prompt"); + } + // Don't await - events will stream // Extension commands are executed immediately, file prompt templates are expanded // If streaming and streamingBehavior specified, queues via steer/followUp @@ -556,8 +630,11 @@ export async function runRpcMode( return success(id, "abort_and_prompt"); } - case "new_session": { + case "new_session": + case "switch_session": + case "branch": { const result = await handleRpcSessionChange(session, command, subagentRegistry); + if (!result.data.cancelled) await emitAvailableCommandsUpdate(); return success(id, result.type, result.data); } @@ -592,6 +669,10 @@ export async function runRpcMode( return success(id, "get_state", state); } + case "get_available_commands": { + return success(id, "get_available_commands", { commands: await getAvailableCommands() }); + } + case "set_todos": { session.setTodoPhases(command.phases); return success(id, "set_todos", { todoPhases: session.getTodoPhases() }); @@ -770,12 +851,6 @@ export async function runRpcMode( return success(id, "export_html", { path }); } - case "switch_session": - case "branch": { - const result = await handleRpcSessionChange(session, command, subagentRegistry); - return success(id, result.type, result.data); - } - case "get_branch_messages": { const messages = session.getUserMessagesForBranching(); return success(id, "get_branch_messages", { messages }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 8ee5f421f..efbbada43 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -11,6 +11,7 @@ import type { BashResult } from "../../exec/bash-executor"; import type { ContextUsage } from "../../extensibility/extensions/types"; import type { AgentSessionEvent, SessionStats } from "../../session/agent-session"; import type { FileEntry } from "../../session/session-manager"; +import type { AvailableSlashCommandSource } from "../../slash-commands/available-commands"; import type { AgentProgress, SubagentEventPayload, @@ -34,6 +35,7 @@ export type RpcCommand = // State | { id?: string; type: "get_state" } + | { id?: string; type: "get_available_commands" } | { id?: string; type: "set_todos"; phases: TodoPhase[] } | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] } | { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] } @@ -110,6 +112,20 @@ export interface RpcSessionState { contextUsage?: ContextUsage; } +export interface RpcAvailableSlashCommand { + name: string; + aliases?: string[]; + description?: string; + input?: { hint?: string }; + subcommands?: Array<{ name: string; description?: string; usage?: string }>; + source: AvailableSlashCommandSource; +} + +export interface RpcAvailableCommandsUpdateFrame { + type: "available_commands_update"; + commands: RpcAvailableSlashCommand[]; +} + export interface RpcHandoffResult { savedPath?: string; } @@ -156,6 +172,13 @@ export type RpcResponse = // State | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState } + | { + id?: string; + type: "response"; + command: "get_available_commands"; + success: true; + data: { commands: RpcAvailableSlashCommand[] }; + } | { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } } | { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } } | { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cc3a40523..dfa732b23 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -298,6 +298,7 @@ export type AgentSessionEvent = /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; +export type CommandMetadataChangedListener = () => void | Promise; export type AsyncJobSnapshotItem = Pick; const EMPTY_STOP_MAX_RETRIES = 3; @@ -884,6 +885,7 @@ export class AgentSession { /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */ #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined }; #eventListeners: AgentSessionEventListener[] = []; + #commandMetadataChangedListeners: CommandMetadataChangedListener[] = []; /** Tracks pending steering messages for UI display. Removed when delivered. * Entry shape: `{ text }` for plain-text steers (user-message dequeue @@ -3034,6 +3036,27 @@ export class AgentSession { }; } + subscribeCommandMetadataChanged(listener: CommandMetadataChangedListener): () => void { + this.#commandMetadataChangedListeners.push(listener); + return () => { + const index = this.#commandMetadataChangedListeners.indexOf(listener); + if (index !== -1) { + this.#commandMetadataChangedListeners.splice(index, 1); + } + }; + } + + #notifyCommandMetadataChanged(): void { + const listeners = [...this.#commandMetadataChangedListeners]; + for (const listener of listeners) { + try { + void listener(); + } catch (err) { + logger.error("Command metadata listener threw", { err }); + } + } + } + /** * Temporarily disconnect from agent events. * User listeners are preserved and will receive events again after resubscribe(). @@ -4350,9 +4373,15 @@ export class AgentSession { return [...this.#customCommands, ...this.#mcpPromptCommands]; } + /** MCP prompt commands only, for command-list metadata. */ + get mcpPromptCommands(): ReadonlyArray { + return this.#mcpPromptCommands; + } + /** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */ setMCPPromptCommands(commands: LoadedCustomCommand[]): void { this.#mcpPromptCommands = commands; + this.#notifyCommandMetadataChanged(); } // ========================================================================= diff --git a/packages/coding-agent/src/slash-commands/available-commands.ts b/packages/coding-agent/src/slash-commands/available-commands.ts new file mode 100644 index 000000000..a2f56b5cb --- /dev/null +++ b/packages/coding-agent/src/slash-commands/available-commands.ts @@ -0,0 +1,105 @@ +import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import type { SkillsSettings } from "../config/settings"; +import type { LoadedCustomCommand } from "../extensibility/custom-commands"; +import type { ExtensionRunner } from "../extensibility/extensions"; +import { getSkillSlashCommandName, type Skill } from "../extensibility/skills"; +import { type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands"; +import { ACP_BUILTIN_RESERVED_NAMES, isAcpBuiltinShadowedName } from "./acp-builtins"; +import { BUILTIN_SLASH_COMMANDS_INTERNAL } from "./builtin-registry"; + +export type AvailableSlashCommandSource = "builtin" | "skill" | "extension" | "custom" | "mcp_prompt" | "file"; + +export interface InternalAvailableSlashCommand { + name: string; + aliases?: string[]; + description?: string; + input?: { hint: string }; + subcommands?: Array<{ name: string; description?: string; usage?: string }>; + source: AvailableSlashCommandSource; +} + +export interface AvailableCommandsSession { + readonly extensionRunner?: ExtensionRunner; + readonly customCommands: ReadonlyArray; + readonly mcpPromptCommands?: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly skillsSettings?: SkillsSettings; + setSlashCommands(slashCommands: FileSlashCommand[]): void; + sessionManager: { getCwd(): string }; +} + +export async function buildAvailableSlashCommands( + session: AvailableCommandsSession, + loadFileCommands: (cwd: string) => Promise = cwd => loadSlashCommands({ cwd }), +): Promise { + const commands: InternalAvailableSlashCommand[] = []; + const seenNames = new Set(); + const appendCommand = (command: InternalAvailableSlashCommand): void => { + if (seenNames.has(command.name)) return; + seenNames.add(command.name); + commands.push(command); + }; + + for (const command of BUILTIN_SLASH_COMMANDS_INTERNAL) { + if (!command.handle) continue; + const hint = command.acpInputHint ?? command.inlineHint; + appendCommand({ + name: command.name, + aliases: command.aliases, + description: command.acpDescription ?? command.description, + input: hint ? { hint } : undefined, + subcommands: command.subcommands, + source: "builtin", + }); + } + + if (session.skillsSettings?.enableSkillCommands) { + for (const skill of session.skills) { + appendCommand({ + name: getSkillSlashCommandName(skill), + description: skill.description || `Run ${skill.name} skill`, + input: { hint: "arguments" }, + source: "skill", + }); + } + } + + const runner = session.extensionRunner; + if (runner) { + for (const command of runner.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES)) { + if (isAcpBuiltinShadowedName(command.name)) continue; + appendCommand({ + name: command.name, + description: command.description ?? "(extension command)", + input: { hint: "arguments" }, + source: "extension", + }); + } + } + + for (const command of session.customCommands) { + const source: AvailableSlashCommandSource = command.path?.startsWith("mcp:") ? "mcp_prompt" : "custom"; + appendCommand({ + name: command.command.name, + description: command.command.description, + input: { hint: "arguments" }, + source, + }); + } + + const fileCommands = await loadFileCommands(session.sessionManager.getCwd()); + session.setSlashCommands(fileCommands); + for (const command of fileCommands) { + appendCommand({ name: command.name, description: command.description, source: "file" }); + } + + return commands; +} + +export function toAcpAvailableCommands(commands: readonly InternalAvailableSlashCommand[]): AvailableCommand[] { + return commands.map(command => ({ + name: command.name, + description: command.description ?? "", + input: command.input, + })); +} diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 57244f200..a3d0f664b 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -477,7 +477,7 @@ async function createHarness( * `setTimeout` drift without slowing tests meaningfully. */ async function waitForBootstrapGuard(): Promise { - await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 30); + await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 150); } describe("ACP agent", () => { diff --git a/packages/coding-agent/test/available-commands.test.ts b/packages/coding-agent/test/available-commands.test.ts new file mode 100644 index 000000000..3ce705a3d --- /dev/null +++ b/packages/coding-agent/test/available-commands.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { buildAvailableSlashCommands } from "@oh-my-pi/pi-coding-agent/slash-commands/available-commands"; + +describe("buildAvailableSlashCommands", () => { + test("returns RPC-safe command metadata with stable sources", async () => { + const fileCommands = [{ name: "notes", description: "Open notes", content: "body", source: "test" }]; + const mcpPrompt = { + path: "mcp:server/prompt", + resolvedPath: "mcp:server/prompt", + source: "project", + command: { name: "server:prompt", description: "MCP prompt" }, + }; + const session = { + extensionRunner: { + getRegisteredCommands: () => [{ name: "ext:hello", description: "Extension hello" }], + }, + customCommands: [ + mcpPrompt, + { + path: "custom.ts", + resolvedPath: "custom.ts", + source: "project", + command: { name: "custom:hello", description: "Custom hello" }, + }, + ], + mcpPromptCommands: [mcpPrompt], + skills: [{ name: "reviewer", description: "Review code", filePath: "/tmp/reviewer/SKILL.md" }], + skillsSettings: { enableSkillCommands: true }, + sessionManager: { getCwd: () => process.cwd() }, + setSlashCommands(commands: typeof fileCommands) { + expect(commands).toEqual(fileCommands); + }, + }; + + const commands = await buildAvailableSlashCommands(session as never, async () => fileCommands); + const byName = Object.fromEntries(commands.map(command => [command.name, command])); + + expect(byName.model.source).toBe("builtin"); + expect(byName["skill:reviewer"].source).toBe("skill"); + expect(byName["ext:hello"].source).toBe("extension"); + expect(byName["server:prompt"].source).toBe("mcp_prompt"); + expect(byName["custom:hello"].source).toBe("custom"); + expect(byName.notes.source).toBe("file"); + }); + + test("loads file commands into the session before advertising them", async () => { + const fileCommands = [{ name: "notes", description: "Open notes", content: "body", source: "test" }]; + let loadedCommands: typeof fileCommands | undefined; + + const commands = await buildAvailableSlashCommands( + { + customCommands: [], + skills: [], + sessionManager: { getCwd: () => process.cwd() }, + setSlashCommands(commands: typeof fileCommands) { + loadedCommands = commands; + }, + } as never, + async () => fileCommands, + ); + + expect(loadedCommands).toEqual(fileCommands); + expect(commands.find(command => command.name === "notes")?.source).toBe("file"); + }); + + test("classifies MCP prompts by path and bundled custom commands as custom", async () => { + const commands = await buildAvailableSlashCommands( + { + customCommands: [ + { + path: "mcp:server/prompt", + resolvedPath: "mcp:server/prompt", + source: "project", + command: { name: "server:prompt", description: "MCP prompt" }, + }, + { + path: "green.md", + resolvedPath: "green.md", + source: "bundled", + command: { name: "green", description: "Bundled custom command" }, + }, + ], + skills: [], + sessionManager: { getCwd: () => process.cwd() }, + setSlashCommands() {}, + } as never, + async () => [], + ); + + const byName = Object.fromEntries(commands.map(command => [command.name, command])); + expect(byName["server:prompt"].source).toBe("mcp_prompt"); + expect(byName.green.source).toBe("custom"); + }); + + test("keeps legacy custom command fixtures without a path classified as custom", async () => { + const commands = await buildAvailableSlashCommands( + { + customCommands: [{ command: { name: "legacy", description: "Legacy fixture" } }], + skills: [], + sessionManager: { getCwd: () => process.cwd() }, + setSlashCommands() {}, + } as never, + async () => [], + ); + + expect(commands.find(command => command.name === "legacy")?.source).toBe("custom"); + }); +}); diff --git a/packages/coding-agent/test/rpc-skill-command.test.ts b/packages/coding-agent/test/rpc-skill-command.test.ts new file mode 100644 index 000000000..2fc4eb8e1 --- /dev/null +++ b/packages/coding-agent/test/rpc-skill-command.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { tryRunRpcSkillCommand } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode"; +import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { Snowflake } from "@oh-my-pi/pi-utils"; + +describe("tryRunRpcSkillCommand", () => { + test("dispatches registered /skill commands as skill prompt messages", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `omp-rpc-skill-${Snowflake.next()}-`)); + const skillPath = path.join(dir, "SKILL.md"); + await Bun.write( + skillPath, + "---\nname: reviewer\ndescription: Review code\n---\n\nReview the supplied code carefully.\n", + ); + + let message: Pick | undefined; + + const handled = await tryRunRpcSkillCommand( + { + skillsSettings: { enableSkillCommands: true }, + skills: [ + { name: "reviewer", description: "Review code", filePath: skillPath, baseDir: dir, source: "project" }, + ], + async promptCustomMessage(nextMessage: typeof message) { + message = nextMessage; + }, + }, + "/skill:reviewer focus on risks", + ); + + expect(handled).toBe(true); + expect(message?.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); + expect(message?.content).toContain("Review the supplied code carefully."); + expect(message?.content).toContain("User: focus on risks"); + expect(message?.display).toBe(true); + expect(message?.attribution).toBe("user"); + + await fs.rm(dir, { recursive: true, force: true }); + }); + + test("ignores unknown skill commands so normal prompt handling can continue", async () => { + const handled = await tryRunRpcSkillCommand( + { + skillsSettings: { enableSkillCommands: true }, + skills: [], + async promptCustomMessage() { + throw new Error("should not dispatch unknown skills"); + }, + }, + "/skill:missing", + ); + + expect(handled).toBe(false); + }); +});