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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user