Merge PR #2262: feat(rpc): expose slash command metadata

This commit is contained in:
can1357
2026-06-11 17:59:40 +02:00
10 changed files with 443 additions and 75 deletions
+2
View File
@@ -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 <path>` CLI overlays for temporary `config.yml`-style settings without editing the persistent global config ([#1733](https://github.com/can1357/oh-my-pi/issues/1733)).
@@ -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<AvailableCommand[]> {
const commands: AvailableCommand[] = [];
const seenNames = new Set<string>();
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 {
@@ -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<TDetails = unknown> {
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<RpcSubagentLifecycleListener>();
#subagentProgressListeners = new Set<RpcSubagentProgressListener>();
#subagentEventListeners = new Set<RpcSubagentEventListener>();
#availableCommandsUpdateListeners = new Set<RpcAvailableCommandsUpdateListener>();
#pendingRequests: Map<string, { resolve: (response: RpcResponse) => 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<RpcAvailableSlashCommand[]> {
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) {
@@ -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<AgentSession, "newSession" | "switchSession" | "branch">;
export type RpcSkillCommandSession = Pick<AgentSession, "promptCustomMessage" | "skills" | "skillsSettings">;
export async function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string): Promise<boolean> {
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<RpcSubagentRegistry, "clear">;
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<RpcResponse> => {
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 });
@@ -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[] } }
@@ -298,6 +298,7 @@ export type AgentSessionEvent =
/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
export type CommandMetadataChangedListener = () => void | Promise<void>;
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
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<LoadedCustomCommand> {
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();
}
// =========================================================================
@@ -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<LoadedCustomCommand>;
readonly mcpPromptCommands?: ReadonlyArray<LoadedCustomCommand>;
readonly skills: ReadonlyArray<Skill>;
readonly skillsSettings?: SkillsSettings;
setSlashCommands(slashCommands: FileSlashCommand[]): void;
sessionManager: { getCwd(): string };
}
export async function buildAvailableSlashCommands(
session: AvailableCommandsSession,
loadFileCommands: (cwd: string) => Promise<FileSlashCommand[]> = cwd => loadSlashCommands({ cwd }),
): Promise<InternalAvailableSlashCommand[]> {
const commands: InternalAvailableSlashCommand[] = [];
const seenNames = new Set<string>();
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,
}));
}
+1 -1
View File
@@ -477,7 +477,7 @@ async function createHarness(
* `setTimeout` drift without slowing tests meaningfully.
*/
async function waitForBootstrapGuard(): Promise<void> {
await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 30);
await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 150);
}
describe("ACP agent", () => {
@@ -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");
});
});
@@ -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<CustomMessage, "attribution" | "content" | "customType" | "details" | "display"> | 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);
});
});