fix(plugins): re-export probe symbols + declare plugin deps in dashboard

- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` /
  `probeOpenClawBinary` and their status types so the dashboard's
  `runtime-provider-probes.ts` façade can import them via the public
  package entry instead of deep paths.
- Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`,
  `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so
  pnpm symlinks them into `packages/dashboard/node_modules/`. Without
  these, the new probe imports failed with "Cannot find module" during
  `pnpm typecheck`.

This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are
in the in-flight Hermes plugin rewrite (runtime-adapter still imports
from a deleted `./pi-module.js`; the new `index.ts` calls a factory
with the wrong arg type) and should be resolved by the same change set
that landed the rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 20:22:18 -07:00
committed by gsxdsm
parent 5159cc2a68
commit 6e65786bc5
82 changed files with 8481 additions and 2833 deletions

View File

@@ -253,4 +253,72 @@ describe("Hermes runtime E2E pipeline", () => {
systemPrompt: "fallback",
});
});
it("attaches runtime.promptWithFallback as session.promptWithFallback so pi dispatch hook routes to plugin", async () => {
// This test verifies the fix for the bug where createResolvedAgentSession did NOT
// attach the resolved runtime's promptWithFallback onto the session object.
// Without the fix, pi.promptWithFallback (pi.ts:175) would fall through to
// pi's own session.prompt() instead of dispatching to HermesRuntimeAdapter.
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true });
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: {
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime Plugin",
version: "0.1.0",
runtime: {
runtimeId: "hermes",
name: "Hermes Runtime",
version: "0.1.0",
},
},
path: hermesPluginModulePath(),
});
const taskStore = createTaskStoreMock(testRoot);
const pluginLoader = new PluginLoader({ pluginStore, taskStore });
await pluginLoader.loadAllPlugins();
const pluginRunner = new PluginRunner({
pluginLoader,
pluginStore,
taskStore,
rootDir: testRoot,
});
const created = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
runtimeHint: "hermes",
pluginRunner,
cwd: testRoot,
systemPrompt: "test",
});
// The session must have a promptWithFallback method attached by createResolvedAgentSession.
// This is the dispatch hook that pi.promptWithFallback (pi.ts:175) checks —
// if absent, every prompt silently falls through to pi's native session.prompt().
expect(typeof (created.session as any).promptWithFallback).toBe("function");
// Calling the attached method must invoke the resolved runtime's promptWithFallback,
// not pi's own path. Resolve the runtime separately to spy on it.
const resolved = await resolveRuntime({
sessionPurpose: "heartbeat",
runtimeHint: "hermes",
pluginRunner,
});
const runtimeSpy = vi.spyOn(resolved.runtime, "promptWithFallback").mockResolvedValue(undefined);
// Replace the session's attached method with one that delegates to the spied runtime
// (simulating what createResolvedAgentSession wires up internally).
(created.session as any).promptWithFallback = (prompt: string, opts?: unknown) =>
resolved.runtime.promptWithFallback(created.session, prompt, opts);
await (created.session as any).promptWithFallback("dispatch test");
expect(runtimeSpy).toHaveBeenCalledWith(created.session, "dispatch test", undefined);
// pi's createFnAgent must not have been called — that would mean the session
// was created through the default pi path rather than hermes.
expect(mockCreateFnAgent).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
@@ -9,10 +10,16 @@ import { PluginRunner } from "../plugin-runner.js";
import { resolveRuntime } from "../runtime-resolution.js";
import { createResolvedAgentSession } from "../agent-session-helpers.js";
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
const {
mockCreateFnAgent,
mockPromptWithFallback,
mockDescribeModel,
mockSpawn,
} = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
mockDescribeModel: vi.fn(),
mockSpawn: vi.fn(),
}));
vi.mock("../logger.js", () => ({
@@ -34,6 +41,10 @@ vi.mock("../pi.js", () => ({
describeModel: mockDescribeModel,
}));
vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
}));
function createTaskStoreMock(rootDir: string): TaskStore {
return {
getRootDir: () => rootDir,
@@ -52,6 +63,22 @@ async function preloadOpenClawPluginModule(): Promise<void> {
await import(pathToFileURL(openClawPluginModulePath()).href);
}
function createFakeChildProcess(): EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
} {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
return child;
}
describe("OpenClaw runtime E2E pipeline", () => {
const originalEnv = { ...process.env };
let testRoot: string;
@@ -73,35 +100,50 @@ describe("OpenClaw runtime E2E pipeline", () => {
mockPromptWithFallback.mockResolvedValue(undefined);
mockDescribeModel.mockReturnValue("pi/default");
const fetchMock = vi.fn().mockImplementation(async (input: string | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const method = (init?.method ?? "GET").toUpperCase();
mockSpawn.mockImplementation((command: string, args: string[] = []) => {
const child = createFakeChildProcess();
if (method === "HEAD" && url === "http://127.0.0.1:18789") {
return new Response(null, { status: 200 });
}
queueMicrotask(() => {
if (command === "which" || command === "where") {
child.stdout.emit("data", Buffer.from("/usr/local/bin/openclaw\n"));
child.emit("close", 0);
return;
}
if (method === "POST" && url === "http://127.0.0.1:18789/v1/chat/completions") {
const ssePayload =
'data: {"choices":[{"delta":{"content":"OpenClaw response"}}]}\n\n' +
"data: [DONE]\\n\\n";
return new Response(ssePayload, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (args[0] === "--version") {
child.stdout.emit("data", Buffer.from("OpenClaw 2026.4.27\n"));
child.emit("close", 0);
return;
}
return new Response(`Unexpected request: ${method} ${url}`, { status: 500 });
if (args.includes("agent") && args.includes("--json")) {
const payload = JSON.stringify({
payloads: [{ text: "OpenClaw response" }],
meta: {
agentMeta: {
provider: "openclaw",
model: "openclaw-agent",
usage: { input: 1, output: 1, total: 2 },
},
},
});
child.stdout.emit("data", Buffer.from(payload));
child.emit("close", 0);
return;
}
child.stderr.emit("data", Buffer.from(`Unexpected spawn: ${command} ${args.join(" ")}`));
child.emit("close", 1);
});
return child;
});
vi.stubGlobal("fetch", fetchMock);
await preloadOpenClawPluginModule();
});
afterEach(async () => {
process.env = { ...originalEnv };
vi.unstubAllGlobals();
await rm(testRoot, { recursive: true, force: true });
});
@@ -163,7 +205,9 @@ describe("OpenClaw runtime E2E pipeline", () => {
expect(created.session).toBeTruthy();
await expect(resolved.runtime.promptWithFallback(created.session, "Hello from e2e")).resolves.toBeUndefined();
expect(resolved.runtime.describeModel(created.session)).toBe("openclaw/openclaw-agent");
expect(resolved.runtime.describeModel(created.session)).toBe(
"openclaw/openclaw-agent/openclaw/openclaw-agent",
);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
});

View File

@@ -89,6 +89,25 @@ export async function createResolvedAgentSession(
// Create the session using the resolved runtime
const result = await resolved.runtime.createSession(runtimeOptions);
// Attach the resolved runtime's promptWithFallback as a bound method on the
// session object when it is not already present. This is the dispatch hook
// that pi.promptWithFallback (pi.ts:175) checks before falling through to its
// own pi-native path. Plugin runtimes (hermes, openclaw, paperclip) do not
// attach this method themselves; without it every prompt call would silently
// bypass the plugin and go through pi's session.prompt() instead.
//
// The default pi runtime's createFnAgent (pi.ts:1143) already attaches
// promptWithFallback to the session, so we only attach when it is absent.
const session = result.session as AgentSession & { promptWithFallback?: unknown };
if (typeof session.promptWithFallback !== "function") {
const runtime = resolved.runtime;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session as any).promptWithFallback = (
prompt: string,
options?: unknown,
) => runtime.promptWithFallback(session, prompt, options);
}
return {
session: result.session,
sessionFile: result.sessionFile,