Fix OMP slash commands and skills loading (#1698)

* Fix OMP slash command discovery

Keep Pi on get_commands while allowing the OMP-backed provider to use get_available_commands. Both providers reject the other command without echoing an id, so the adapter needs an explicit command-list RPC setting instead of a timeout-based fallback.

* Address OMP command test review
This commit is contained in:
Mohamed Boudra
2026-06-24 15:11:05 +08:00
committed by GitHub
parent 79be6d8dba
commit 2ef119c24b
7 changed files with 88 additions and 12 deletions
+2
View File
@@ -26,6 +26,8 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
@@ -12,6 +12,7 @@ const mockState = vi.hoisted(() => {
interface ConstructorEntry {
runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown;
}
return {
@@ -214,12 +215,20 @@ vi.mock("./providers/pi/agent.js", () => ({
readonly provider = "pi";
readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
constructor(options: {
runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown;
}) {
this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.pi.push({
const entry: ConstructorEntry = {
runtimeSettings: options.runtimeSettings,
providerParams: options.providerParams,
});
};
if (options.commandsRpcType !== undefined) {
entry.commandsRpcType = options.commandsRpcType;
}
mockState.constructorArgs.pi.push(entry);
}
async createSession(): Promise<never> {
@@ -458,6 +467,7 @@ test("OMP is a disabled built-in backed by the Pi adapter", () => {
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
});
@@ -488,11 +498,10 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("new provider extending pi passes params to the base provider constructor", () => {
test("built-in OMP override passes params to the Pi adapter constructor", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
omp: {
extends: "pi",
label: "OMP",
command: ["omp"],
params: {
@@ -515,6 +524,7 @@ test("new provider extending pi passes params to the base provider constructor",
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
});
@@ -156,6 +156,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
providerParams: options?.providerParams ?? {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
@@ -62,6 +62,7 @@ import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
import type {
PiAgentSessionEvent,
PiAgentMessage,
PiCommandsRpcType,
PiImageContent,
PiModel,
PiRpcSlashCommand,
@@ -154,6 +155,7 @@ interface PiRpcAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
providerParams?: unknown;
commandsRpcType?: PiCommandsRpcType;
runtime?: PiRuntime;
}
@@ -967,8 +969,12 @@ function mapPiModel(model: PiModel): AgentModelDefinition {
};
}
function createRuntime(logger: Logger, runtimeSettings?: ProviderRuntimeSettings): PiRuntime {
return new PiCliRuntime({ logger, runtimeSettings });
function createRuntime(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
commandsRpcType?: PiCommandsRpcType,
): PiRuntime {
return new PiCliRuntime({ logger, runtimeSettings, commandsRpcType });
}
export class PiRpcAgentSession implements AgentSession {
@@ -1866,7 +1872,9 @@ export class PiRpcAgentClient implements AgentClient {
this.logger = options.logger;
this.runtimeSettings = options.runtimeSettings;
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {});
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings);
this.runtime =
options.runtime ??
createRuntime(options.logger, options.runtimeSettings, options.commandsRpcType);
}
async createSession(
@@ -5,6 +5,7 @@ import pino from "pino";
import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiCommandsRpcType } from "./rpc-types.js";
import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & {
@@ -31,10 +32,15 @@ function createPiChild(): PiChild {
return child;
}
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime {
function createRuntime(
child: PiChild,
launches: PiRuntimeLaunch[] = [],
options?: { commandsRpcType?: PiCommandsRpcType },
): PiCliRuntime {
return new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
commandsRpcType: options?.commandsRpcType,
spawnProcess: (launch) => {
launches.push(launch);
return child;
@@ -184,6 +190,42 @@ describe("PiCliRuntime", () => {
expect(events).toEqual([{ type: "turn_start" }]);
});
test("lists commands through the default Pi get_commands RPC", async () => {
const child = createPiChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [{ name: "review", description: "Review changes", source: "extension" }],
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{ name: "review", description: "Review changes", source: "extension" },
]);
expect(commandTypes).toEqual(["get_commands"]);
});
test("lists commands through the OMP-compatible get_available_commands RPC", async () => {
const child = createPiChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" }],
};
});
const session = await createRuntime(child, [], {
commandsRpcType: "get_available_commands",
}).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" },
]);
expect(commandTypes).toEqual(["get_available_commands"]);
});
test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
@@ -13,6 +13,7 @@ import {
} from "./runtime.js";
import type {
PiAgentMessage,
PiCommandsRpcType,
PiModel,
PiRpcCommand,
PiRpcResponse,
@@ -26,6 +27,7 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
];
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_COMMANDS_RPC_TYPE: PiCommandsRpcType = "get_commands";
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -48,15 +50,18 @@ export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
commandsRpcType?: PiCommandsRpcType;
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]];
private readonly commandsRpcType: PiCommandsRpcType;
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND;
this.commandsRpcType = options.commandsRpcType ?? DEFAULT_COMMANDS_RPC_TYPE;
this.spawnProcess =
options.spawnProcess ??
((launch) => {
@@ -77,7 +82,12 @@ export class PiCliRuntime implements PiRuntime {
runtimeSettings: this.options.runtimeSettings,
session: input,
});
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger);
return new PiCliRuntimeSession(
launch,
this.spawnProcess(launch),
this.options.logger,
this.commandsRpcType,
);
}
}
@@ -93,6 +103,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
_launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
private readonly commandsRpcType: PiCommandsRpcType,
) {
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
@@ -173,7 +184,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: "get_commands" })) as {
const data = (await this.request({ type: this.commandsRpcType })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
@@ -110,6 +110,8 @@ export interface PiRpcSlashCommand {
sourceInfo?: Record<string, unknown>;
}
export type PiCommandsRpcType = "get_commands" | "get_available_commands";
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "compact"; customInstructions?: string }
@@ -121,7 +123,7 @@ export type PiRpcCommand =
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "get_commands" };
| { id?: string; type: PiCommandsRpcType };
export interface PiRpcResponse {
id?: string;