## Summary - Add `fusion-plugin-omp-runtime` so Fusion agents can run through operator-installed **Oh My Pi (`omp`)** over the [Agent Client Protocol](https://omp.sh/docs/acp) (`omp acp`). - Wire staged/bundled install, Settings → Authentication card (enable + binary path), model discovery (`omp models` → `omp-cli/*`), and MCP eligibility for runtime id `omp`. - Forward Fusion `systemPrompt` via ACP `session/new` `_meta.systemPromptOverride`. ## How operators use it 1. Install/auth `omp` (credentials under `~/.omp`). 2. Enable **Oh My Pi — via omp ACP** in Settings → Authentication (optional binary path). 3. Set agent **Runtime Source → OMP Runtime** (`runtimeHint: "omp"`), or pick an `omp-cli/*` model when enabled. ## Known v1 gaps - No Grok-style Fusion `fn_*` loopback tool bridge yet (operator MCP is forwarded; in-process custom tools are not). - Model is fixed at spawn (`omp --model … acp`); no mid-session Fusion model switch. ## Test plan - [x] `pnpm --filter @fusion-plugin-examples/omp-runtime test` (unit + live ACP when `omp` is on PATH) - [x] Auth routes: `POST /api/auth/omp-cli`, `GET /api/providers/omp-cli/status` - [x] Engine `runtimeSupportsMcp("omp")` - [ ] Manual: enable card in dashboard, select OMP runtime on an agent, run a short chat turn <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Oh My Pi (OMP) CLI support as an ACP-backed runtime and model provider, including model discovery and probing. * Added dashboard auth/status controls to enable OMP, check readiness, and configure the local binary path (with validation). * Exposed OMP custom `fn_*` tools via an MCP loopback bridge, plus optional filesystem capabilities and stricter tool permission gating. * **Documentation** * Added/expanded OMP runtime contract and integration docs (including the ACP session/handshake flow). * **Tests** * Added Vitest coverage for settings wiring, provider status, model discovery, runtime sessions, permissions, MCP bridging, and live connectivity. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { OmpRuntimeAdapter } from "../runtime-adapter.js";
|
|
import type { AgentRuntimeOptions, AgentSession, AgentSessionResult } from "../types.js";
|
|
|
|
function makeFakeSession(partial?: Partial<AgentSession>): AgentSession {
|
|
const messages: unknown[] = [];
|
|
return {
|
|
model: "omp/default",
|
|
messages,
|
|
state: { messages },
|
|
lastModelDescription: "omp/default",
|
|
callbacks: {},
|
|
connection: { live: true },
|
|
dispose: () => undefined,
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
describe("OmpRuntimeAdapter", () => {
|
|
it("forwards Fusion systemPrompt via sessionMeta.systemPromptOverride", async () => {
|
|
let seenOptions: AgentRuntimeOptions | undefined;
|
|
const liveSession = makeFakeSession();
|
|
const adapter = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async (options) => {
|
|
seenOptions = options;
|
|
return { session: liveSession };
|
|
},
|
|
promptWithFallback: async () => undefined,
|
|
describeModel: () => "omp/default",
|
|
}),
|
|
});
|
|
|
|
await adapter.createSession({
|
|
cwd: process.cwd(),
|
|
systemPrompt: "Fusion system context",
|
|
});
|
|
|
|
expect(seenOptions?.sessionMeta).toEqual(
|
|
expect.objectContaining({
|
|
systemPromptOverride: expect.stringContaining("Fusion system context"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("forwards Fusion fn_* tools via fusion-custom-tools MCP server", async () => {
|
|
let seenOptions: AgentRuntimeOptions | undefined;
|
|
const liveSession = makeFakeSession();
|
|
const adapter = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async (options) => {
|
|
seenOptions = options;
|
|
return { session: liveSession };
|
|
},
|
|
promptWithFallback: async () => undefined,
|
|
describeModel: () => "omp/default",
|
|
}),
|
|
});
|
|
|
|
const { session } = await adapter.createSession({
|
|
cwd: process.cwd(),
|
|
systemPrompt: "sys",
|
|
customTools: [
|
|
{
|
|
name: "fn_task_list",
|
|
description: "List tasks",
|
|
parameters: { type: "object", properties: {} },
|
|
execute: async () => ({ text: "ok" }),
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
const mcp = seenOptions?.mcpServers as Array<{ name?: string }> | undefined;
|
|
expect(mcp?.some((s) => s.name === "fusion-custom-tools")).toBe(true);
|
|
expect(String(seenOptions?.sessionMeta?.systemPromptOverride ?? "")).toContain(
|
|
"fusion-custom-tools",
|
|
);
|
|
} finally {
|
|
await adapter.dispose(session);
|
|
}
|
|
});
|
|
|
|
it("surfaces create failures as onText diagnostics without throwing", async () => {
|
|
const onText = vi.fn();
|
|
const adapter = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async () => {
|
|
throw new Error("spawn ENOENT");
|
|
},
|
|
promptWithFallback: async () => undefined,
|
|
describeModel: () => "omp/default",
|
|
}),
|
|
});
|
|
|
|
const { session } = await adapter.createSession({
|
|
cwd: process.cwd(),
|
|
systemPrompt: "",
|
|
onText,
|
|
});
|
|
|
|
expect(onText).toHaveBeenCalled();
|
|
expect(String(onText.mock.calls[0]?.[0])).toMatch(/OMP ACP failed to start/);
|
|
expect(session.state.errorMessage).toMatch(/OMP ACP failed to start/);
|
|
});
|
|
|
|
it("forwards prompts to the ACP adapter when connection is live", async () => {
|
|
const prompt = vi.fn(async () => ({ stopReason: "end_turn" }));
|
|
const liveSession = makeFakeSession();
|
|
const adapter = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async (): Promise<AgentSessionResult> => ({
|
|
session: liveSession,
|
|
}),
|
|
promptWithFallback: prompt,
|
|
describeModel: () => "omp/default",
|
|
}),
|
|
});
|
|
|
|
const { session } = await adapter.createSession({
|
|
cwd: process.cwd(),
|
|
systemPrompt: "sys",
|
|
onText: (t) => {
|
|
liveSession.callbacks.onText?.(t);
|
|
},
|
|
});
|
|
|
|
// Simulate streamed text during the turn
|
|
liveSession.callbacks.onText = (t: string) => {
|
|
// turn accum is wired on create; call session path via adapter
|
|
void t;
|
|
};
|
|
|
|
await adapter.promptWithFallback(session, "hello");
|
|
expect(prompt).toHaveBeenCalledWith(session, "hello", undefined);
|
|
});
|
|
|
|
it("re-surfaces diagnostics when the session has no live connection", async () => {
|
|
const onText = vi.fn();
|
|
const dead = makeFakeSession({ connection: undefined, callbacks: { onText } });
|
|
const adapter = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async () => ({ session: dead }),
|
|
promptWithFallback: async () => undefined,
|
|
describeModel: () => "omp/default",
|
|
}),
|
|
});
|
|
|
|
// Manually set adapters map by creating then killing connection semantics:
|
|
// createSession without connection still stores adapter, but hasConnection checks connection field.
|
|
const createOnly = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async () => ({
|
|
session: makeFakeSession({
|
|
connection: undefined,
|
|
callbacks: { onText },
|
|
state: { messages: [], errorMessage: "boom" },
|
|
}),
|
|
}),
|
|
promptWithFallback: async () => undefined,
|
|
describeModel: () => "omp/default",
|
|
}),
|
|
});
|
|
|
|
const { session } = await createOnly.createSession({
|
|
cwd: process.cwd(),
|
|
systemPrompt: "",
|
|
onText,
|
|
});
|
|
// Force no connection for follow-up
|
|
(session as { connection?: unknown }).connection = undefined;
|
|
onText.mockClear();
|
|
await createOnly.promptWithFallback(session, "again");
|
|
expect(onText).toHaveBeenCalled();
|
|
expect(String(onText.mock.calls[0]?.[0])).toMatch(/no live connection/);
|
|
});
|
|
|
|
it("describeModel uses lastModelDescription", async () => {
|
|
const adapter = new OmpRuntimeAdapter({
|
|
createAcpAdapter: () => ({
|
|
createSession: async () => ({
|
|
session: makeFakeSession({ lastModelDescription: "omp/claude-sonnet-4", model: "claude-sonnet-4" }),
|
|
}),
|
|
promptWithFallback: async () => undefined,
|
|
describeModel: () => "omp/claude-sonnet-4",
|
|
}),
|
|
});
|
|
const { session } = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "" });
|
|
expect(adapter.describeModel(session)).toMatch(/^omp\//);
|
|
});
|
|
});
|