Merge PR #2262: feat(rpc): expose slash command metadata
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user