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

@@ -0,0 +1,460 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
// ── Mock node:child_process before imports that use it ─────────────────────
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
import {
buildHermesArgs,
invokeHermesCli,
listHermesProfiles,
parseHermesOutput,
resolveCliSettings,
} from "../cli-spawn.js";
import type { HermesCliSettings } from "../cli-spawn.js";
// ── Helpers ────────────────────────────────────────────────────────────────
function defaultSettings(overrides: Partial<HermesCliSettings> = {}): HermesCliSettings {
return {
binaryPath: "hermes",
maxTurns: 12,
yolo: false,
cliTimeoutMs: 5_000,
...overrides,
};
}
/**
* Create a fake ChildProcess-like EventEmitter with controllable
* stdout/stderr streams and a kill spy.
*/
function makeFakeChild(): {
child: ChildProcess;
emitStdout: (data: string) => void;
emitStderr: (data: string) => void;
emitError: (err: Error) => void;
emitClose: (code: number | null) => void;
kill: ReturnType<typeof vi.fn>;
} {
const main = new EventEmitter() as ChildProcess;
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
(main as any).stdout = stdoutEmitter;
(main as any).stderr = stderrEmitter;
const kill = vi.fn().mockReturnValue(true);
(main as any).kill = kill;
return {
child: main,
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
emitError: (err) => main.emit("error", err),
emitClose: (code) => main.emit("close", code),
kill,
};
}
/** Build a stdout string that hermes would produce for a given body + session id. */
function fakeHermesOutput(body: string, sessionId = "20260427_120000_abcd12"): string {
return `${body}\nsession_id: ${sessionId}\n`;
}
// ── resolveCliSettings ──────────────────────────────────────────────────────
describe("resolveCliSettings", () => {
const origEnv = { ...process.env };
afterEach(() => {
process.env = { ...origEnv };
});
it("returns defaults when settings and env are empty", () => {
delete process.env.HERMES_BIN;
delete process.env.HERMES_MODEL_ID;
delete process.env.HERMES_PROVIDER;
delete process.env.HERMES_MAX_TURNS;
delete process.env.HERMES_YOLO;
delete process.env.HERMES_CLI_TIMEOUT_MS;
expect(resolveCliSettings()).toEqual({
binaryPath: "hermes",
model: undefined,
provider: undefined,
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
});
});
it("prefers settings over env vars", () => {
process.env.HERMES_BIN = "/usr/bin/hermes";
process.env.HERMES_MODEL_ID = "env-model";
process.env.HERMES_PROVIDER = "openrouter";
expect(
resolveCliSettings({
binaryPath: "/custom/hermes",
model: "gpt-4o",
provider: "openai-codex",
maxTurns: 5,
yolo: true,
cliTimeoutMs: 10_000,
}),
).toEqual({
binaryPath: "/custom/hermes",
model: "gpt-4o",
provider: "openai-codex",
maxTurns: 5,
yolo: true,
cliTimeoutMs: 10_000,
});
});
it("falls back to env vars when settings omit values", () => {
process.env.HERMES_BIN = "/env/hermes";
process.env.HERMES_MODEL_ID = "env-model";
process.env.HERMES_PROVIDER = "gemini";
process.env.HERMES_MAX_TURNS = "7";
process.env.HERMES_YOLO = "true";
process.env.HERMES_CLI_TIMEOUT_MS = "60000";
expect(resolveCliSettings({})).toEqual({
binaryPath: "/env/hermes",
model: "env-model",
provider: "gemini",
maxTurns: 7,
yolo: true,
cliTimeoutMs: 60_000,
});
});
});
// ── buildHermesArgs ────────────────────────────────────────────────────────
describe("buildHermesArgs", () => {
it("builds minimal args without resume or optional flags", () => {
const args = buildHermesArgs("hello world", defaultSettings());
expect(args).toEqual(["chat", "-q", "hello world", "-Q", "--source", "tool", "--max-turns", "12"]);
});
it("adds --resume when sessionId provided", () => {
const args = buildHermesArgs("hello", defaultSettings(), "20260427_120000_abcd12");
expect(args).toContain("--resume");
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
});
it("adds -m and --provider when configured", () => {
const args = buildHermesArgs(
"hello",
defaultSettings({ model: "claude-sonnet-4-5", provider: "anthropic" }),
);
expect(args).toContain("-m");
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
expect(args).toContain("--provider");
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
});
it("adds --yolo when yolo is true", () => {
const args = buildHermesArgs("hello", defaultSettings({ yolo: true }));
expect(args).toContain("--yolo");
});
it("does not add --yolo when yolo is false", () => {
const args = buildHermesArgs("hello", defaultSettings({ yolo: false }));
expect(args).not.toContain("--yolo");
});
it("uses configured maxTurns", () => {
const args = buildHermesArgs("hello", defaultSettings({ maxTurns: 5 }));
expect(args[args.indexOf("--max-turns") + 1]).toBe("5");
});
it("first call has no --resume arg", () => {
const args = buildHermesArgs("hi", defaultSettings());
expect(args).not.toContain("--resume");
});
});
// ── parseHermesOutput ──────────────────────────────────────────────────────
describe("parseHermesOutput", () => {
it("extracts session_id and body from clean output", () => {
const stdout = "This is the response\nsession_id: 20260427_120000_abcd12\n";
const result = parseHermesOutput(stdout, "");
expect(result.sessionId).toBe("20260427_120000_abcd12");
expect(result.body).toBe("This is the response");
});
it("strips ANSI escape codes from body", () => {
const stdout = "\x1b[32mGreen text\x1b[0m\nsession_id: 20260427_120000_abcd12\n";
const result = parseHermesOutput(stdout, "");
expect(result.body).toBe("Green text");
expect(result.body).not.toContain("\x1b");
});
it("strips preamble chrome lines", () => {
const stdout = [
"╭─ Hermes ─╮",
"↻ Resumed session foo",
" ┊ preparing memory…",
"Query: what is 2+2?",
"4",
"╰─────────╯",
"session_id: 20260427_120000_abcd12",
].join("\n") + "\n";
const result = parseHermesOutput(stdout, "");
expect(result.body).toBe("4");
expect(result.body).not.toContain("Hermes");
expect(result.body).not.toContain("Resumed");
expect(result.body).not.toContain("preparing");
expect(result.body).not.toContain("Query:");
expect(result.body).not.toContain("╭");
expect(result.body).not.toContain("╰");
});
it("normalizes CRLF to LF", () => {
const stdout = "line1\r\nline2\r\nsession_id: 20260427_120000_abcd12\r\n";
const result = parseHermesOutput(stdout, "");
expect(result.body).toBe("line1\nline2");
});
it("throws when session_id line is missing", () => {
expect(() => parseHermesOutput("some output without id", "stderr text")).toThrow(
/missing session_id/,
);
});
});
// ── invokeHermesCli ────────────────────────────────────────────────────────
describe("invokeHermesCli", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("first call passes correct args and no --resume", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const PROMPT = "what is typescript?";
const promise = invokeHermesCli(PROMPT, defaultSettings());
emitStdout(fakeHermesOutput("TypeScript is a language."));
emitClose(0);
const result = await promise;
expect(mockSpawn).toHaveBeenCalledOnce();
const [bin, args] = mockSpawn.mock.calls[0]!;
expect(bin).toBe("hermes");
expect(args).toEqual(["chat", "-q", PROMPT, "-Q", "--source", "tool", "--max-turns", "12"]);
expect(result.body).toBe("TypeScript is a language.");
expect(result.sessionId).toBe("20260427_120000_abcd12");
});
it("subsequent call with sessionId passes --resume", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hello again", defaultSettings(), "20260427_120000_abcd12");
emitStdout(fakeHermesOutput("Hi there!"));
emitClose(0);
await promise;
const args: string[] = mockSpawn.mock.calls[0]![1];
expect(args).toContain("--resume");
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
});
it("captures session id from stdout", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
emitStdout("Hello!\nsession_id: 20260427_120000_abcd12\n");
emitClose(0);
const result = await promise;
expect(result.sessionId).toBe("20260427_120000_abcd12");
});
it("rejects on non-zero exit code and surfaces stderr", async () => {
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
emitStdout("partial output");
emitStderr("fatal error from hermes");
emitClose(1);
await expect(promise).rejects.toThrow(/exited with code 1/);
});
it("rejects when session_id is missing from stdout on exit 0", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
emitStdout("Some output without session id line");
emitClose(0);
await expect(promise).rejects.toThrow(/missing session_id/);
});
it("includes -m, --provider, --max-turns, --yolo when settings configured", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const settings = defaultSettings({
model: "claude-sonnet-4-5",
provider: "anthropic",
maxTurns: 20,
yolo: true,
});
const promise = invokeHermesCli("test", settings);
emitStdout(fakeHermesOutput("ok"));
emitClose(0);
await promise;
const args: string[] = mockSpawn.mock.calls[0]![1];
expect(args).toContain("-m");
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
expect(args).toContain("--provider");
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
expect(args[args.indexOf("--max-turns") + 1]).toBe("20");
expect(args).toContain("--yolo");
});
it("rejects on ENOENT with not-found message", async () => {
const { child, emitError } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = invokeHermesCli("hi", defaultSettings());
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
emitError(err);
await expect(promise).rejects.toThrow(/binary not found/);
});
it("AbortSignal triggers child.kill and rejects", async () => {
const { child, kill, emitStdout } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const ac = new AbortController();
const promise = invokeHermesCli("hi", defaultSettings(), undefined, ac.signal);
// Abort before any output arrives.
ac.abort();
emitStdout("irrelevant");
await expect(promise).rejects.toThrow(/aborted/);
expect(kill).toHaveBeenCalledWith("SIGTERM");
});
});
// ── listHermesProfiles ─────────────────────────────────────────────────────
describe("listHermesProfiles", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const SAMPLE_OUTPUT = [
" Profile Model Gateway Alias",
" ─────────────── ───────────────────────── ─────────── ────────────",
" ◆default MiniMax-M2.7 stopped —",
].join("\n") + "\n";
it("happy path: parses single default profile", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles();
emitStdout(SAMPLE_OUTPUT);
emitClose(0);
const profiles = await promise;
expect(profiles).toHaveLength(1);
expect(profiles[0]).toMatchObject({
name: "default",
model: "MiniMax-M2.7",
gateway: "stopped",
alias: undefined,
isDefault: true,
});
});
it("happy path: multi-profile ordered correctly", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const multiOutput = [
" Profile Model Gateway Alias",
" ─────────────── ───────────────────────── ─────────── ────────────",
" ◆default MiniMax-M2.7 stopped —",
" work claude-sonnet-4-5 running work-hermes",
].join("\n") + "\n";
const promise = listHermesProfiles();
emitStdout(multiOutput);
emitClose(0);
const profiles = await promise;
expect(profiles).toHaveLength(2);
expect(profiles[0]!.name).toBe("default");
expect(profiles[0]!.isDefault).toBe(true);
expect(profiles[1]!.name).toBe("work");
expect(profiles[1]!.isDefault).toBe(false);
expect(profiles[1]!.model).toBe("claude-sonnet-4-5");
expect(profiles[1]!.alias).toBe("work-hermes");
});
it("ENOENT rejects with binary-not-found error", async () => {
const { child, emitError } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles({ binaryPath: "/no/such/hermes" });
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
emitError(err);
await expect(promise).rejects.toThrow(/hermes profile list failed.*binary not found/);
});
it("non-zero exit rejects with exit code error", async () => {
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles();
emitStdout("");
emitStderr("unknown subcommand");
emitClose(2);
await expect(promise).rejects.toThrow(/hermes profile list failed.*exited with code 2/);
});
it("spawns with 'profile list' args", async () => {
const { child, emitStdout, emitClose } = makeFakeChild();
mockSpawn.mockReturnValue(child);
const promise = listHermesProfiles({ binaryPath: "/custom/hermes" });
emitStdout(SAMPLE_OUTPUT);
emitClose(0);
await promise;
expect(mockSpawn).toHaveBeenCalledOnce();
const [bin, args] = mockSpawn.mock.calls[0]!;
expect(bin).toBe("/custom/hermes");
expect(args).toEqual(["profile", "list"]);
});
});

View File

@@ -1,221 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createStreamSession,
describeStreamModel,
resolveModelConfig,
streamPrompt,
} from "../pi-module.js";
const { mockGetModel, mockStreamSimple } = vi.hoisted(() => ({
mockGetModel: vi.fn(),
mockStreamSimple: vi.fn(),
}));
vi.mock("@mariozechner/pi-ai", () => ({
getModel: mockGetModel,
streamSimple: mockStreamSimple,
}));
function createFakeStream(events: unknown[], finalMessage: unknown) {
return {
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
}
},
result: vi.fn().mockResolvedValue(finalMessage),
};
}
describe("hermes pi-ai stream client", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.HERMES_PROVIDER;
delete process.env.HERMES_MODEL_ID;
delete process.env.HERMES_API_KEY;
delete process.env.HERMES_THINKING_LEVEL;
mockGetModel.mockReturnValue({ provider: "anthropic", id: "claude-sonnet-4-5" });
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("resolveModelConfig prefers settings over env and env over defaults", () => {
process.env.HERMES_PROVIDER = "openai";
process.env.HERMES_MODEL_ID = "gpt-5";
process.env.HERMES_API_KEY = "env-key";
process.env.HERMES_THINKING_LEVEL = "medium";
expect(resolveModelConfig()).toEqual({
provider: "openai",
modelId: "gpt-5",
apiKey: "env-key",
thinkingLevel: "medium",
});
expect(
resolveModelConfig({ provider: "anthropic", modelId: "claude", apiKey: "plugin-key", thinkingLevel: "high" }),
).toEqual({
provider: "anthropic",
modelId: "claude",
apiKey: "plugin-key",
thinkingLevel: "high",
});
delete process.env.HERMES_PROVIDER;
delete process.env.HERMES_MODEL_ID;
delete process.env.HERMES_API_KEY;
delete process.env.HERMES_THINKING_LEVEL;
expect(resolveModelConfig()).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: undefined,
thinkingLevel: undefined,
});
});
it("createStreamSession resolves model and initializes session state", () => {
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: "key",
thinkingLevel: "high",
systemPrompt: "You are Hermes",
callbacks: { onText, onThinking, onToolStart, onToolEnd },
});
expect(mockGetModel).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-5");
expect(session.model).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5" });
expect(session.systemPrompt).toBe("You are Hermes");
expect(session.messages).toEqual([]);
expect(session.apiKey).toBe("key");
expect(session.thinkingLevel).toBe("high");
expect(session.callbacks).toEqual({ onText, onThinking, onToolStart, onToolEnd });
expect(session.lastModelDescription).toBe("anthropic/claude-sonnet-4-5");
expect(session.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
const second = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
systemPrompt: "You are Hermes",
});
expect(second.sessionId).not.toBe(session.sessionId);
});
it("streamPrompt streams deltas, handles tool calls, stores usage, and appends assistant text only", async () => {
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: "api-key",
thinkingLevel: "medium",
systemPrompt: "system",
callbacks: { onText, onThinking, onToolStart, onToolEnd },
});
session.messages.push({ role: "user", content: "hello" });
const doneMessage = {
content: [
{ type: "text", text: "Hello" },
{ type: "thinking", thinking: "internal" },
{ type: "toolCall", id: "t1", name: "bash", arguments: { cmd: "ls" } },
{ type: "text", text: " world" },
],
usage: { input: 1, output: 2 },
};
mockStreamSimple.mockReturnValue(
createFakeStream(
[
{ type: "text_delta", delta: "Hello" },
{ type: "thinking_delta", delta: "thinking" },
{ type: "toolcall_end", toolCall: { name: "bash", arguments: { cmd: "ls" } } },
{ type: "text_delta", delta: " world" },
{ type: "done", message: doneMessage },
],
doneMessage,
),
);
await streamPrompt(session, { role: "user", content: "ignored" } as any);
expect(mockStreamSimple).toHaveBeenCalledWith(
session.model,
{
systemPrompt: "system",
messages: [{ role: "user", content: "hello" }],
},
{
sessionId: session.sessionId,
apiKey: "api-key",
reasoning: "medium",
},
);
expect(onText).toHaveBeenNthCalledWith(1, "Hello");
expect(onText).toHaveBeenNthCalledWith(2, " world");
expect(onThinking).toHaveBeenCalledWith("thinking");
expect(onToolStart).toHaveBeenCalledWith("bash", { cmd: "ls" });
expect(onToolEnd).toHaveBeenCalledWith("bash", false, { cmd: "ls" });
expect(session.usage).toEqual({ input: 1, output: 2 });
expect(session.messages).toEqual([
{ role: "user", content: "hello" },
{ role: "assistant", content: "Hello world" },
]);
expect(describeStreamModel(session)).toBe("anthropic/claude-sonnet-4-5");
});
it("streamPrompt omits optional apiKey/reasoning when unset", async () => {
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
systemPrompt: "system",
});
session.messages.push({ role: "user", content: "hello" });
const doneMessage = { content: [{ type: "text", text: "ok" }], usage: { input: 1, output: 1 } };
mockStreamSimple.mockReturnValue(createFakeStream([{ type: "done", message: doneMessage }], doneMessage));
await streamPrompt(session, { role: "user", content: "ignored" } as any);
expect(mockStreamSimple).toHaveBeenCalledWith(
session.model,
{ systemPrompt: "system", messages: [{ role: "user", content: "hello" }] },
{ sessionId: session.sessionId },
);
});
it("streamPrompt throws on error event", async () => {
const session = createStreamSession({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
systemPrompt: "system",
});
const errorMessage = {
type: "error",
error: {
errorMessage: "boom",
},
};
mockStreamSimple.mockReturnValue(createFakeStream([errorMessage], { content: [], usage: {} }));
await expect(streamPrompt(session, { role: "user", content: "ignored" } as any)).rejects.toThrow("boom");
expect(session.messages).toEqual([]);
});
});

View File

@@ -1,47 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveModelConfig } = vi.hoisted(() => ({
mockResolveModelConfig: vi.fn().mockReturnValue({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: undefined,
thinkingLevel: undefined,
const { mockResolveCli } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes",
model: undefined,
provider: undefined,
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
}),
}));
vi.mock("../pi-module.js", () => ({
resolveModelConfig: mockResolveModelConfig,
}));
vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
"../cli-spawn.js",
);
return {
...actual,
resolveCliSettings: mockResolveCli,
};
});
import plugin, { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID } from "../index.js";
import plugin, {
hermesRuntimeMetadata,
hermesRuntimeFactory,
HERMES_RUNTIME_ID,
} from "../index.js";
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(settings: Record<string, unknown> = {}) {
return {
pluginId: "fusion-plugin-hermes-runtime",
settings,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
taskStore: {
getTask: vi.fn(),
},
taskStore: { getTask: vi.fn() },
};
}
describe("hermes-runtime plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveCli.mockReturnValue({
binaryPath: "hermes",
model: undefined,
provider: undefined,
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
});
});
it("has expected manifest identity", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-hermes-runtime");
expect(plugin.manifest.name).toBe("Hermes Runtime Plugin");
expect(plugin.manifest.version).toBe("0.1.0");
expect(plugin.state).toBe("installed");
});
@@ -49,50 +61,35 @@ describe("hermes-runtime plugin", () => {
expect(HERMES_RUNTIME_ID).toBe("hermes");
expect(plugin.runtime?.metadata.runtimeId).toBe("hermes");
expect(plugin.runtime?.metadata.name).toBe("Hermes Runtime");
expect(plugin.runtime?.metadata.description).toContain("pi-ai direct streaming");
expect(plugin.runtime?.metadata.description).toContain("hermes");
expect(plugin.manifest.runtime).toEqual(hermesRuntimeMetadata);
});
it("onLoad resolves model config and logs selected provider/model without api key", async () => {
const ctx = createMockContext({ provider: "openai", modelId: "gpt-5", apiKey: "secret" });
mockResolveModelConfig.mockReturnValue({
provider: "openai",
modelId: "gpt-5",
apiKey: "secret",
thinkingLevel: "medium",
it("onLoad logs selected binary path & model and emits loaded event", async () => {
mockResolveCli.mockReturnValue({
binaryPath: "/opt/homebrew/bin/hermes",
model: "claude-sonnet-4-5",
provider: "anthropic",
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
});
await plugin.hooks.onLoad?.(ctx as any);
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
expect(ctx.logger.info).toHaveBeenCalledWith("Hermes Runtime Plugin loaded — using openai/gpt-5");
expect(ctx.logger.info.mock.calls[0][0]).not.toContain("secret");
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
await plugin.hooks!.onLoad!(ctx as any);
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings);
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("/opt/homebrew/bin/hermes"),
);
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: "0.1.0",
version: plugin.manifest.version,
});
});
it("runtime factory resolves settings and returns HermesRuntimeAdapter", async () => {
const ctx = createMockContext({ provider: "openai", modelId: "gpt-5" });
mockResolveModelConfig.mockReturnValue({
provider: "openai",
modelId: "gpt-5",
apiKey: "api-key",
thinkingLevel: "high",
});
it("factory returns a HermesRuntimeAdapter", async () => {
const ctx = createMockContext({ binaryPath: "hermes" });
const runtime = (await hermesRuntimeFactory(ctx as any)) as HermesRuntimeAdapter;
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
expect(runtime).toBeInstanceOf(HermesRuntimeAdapter);
expect(runtime.id).toBe("hermes");
expect(runtime.name).toBe("Hermes Runtime");
expect((runtime as any).config).toEqual({
provider: "openai",
modelId: "gpt-5",
apiKey: "api-key",
thinkingLevel: "high",
});
});
});

View File

@@ -1,96 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FusionPlugin } from "@fusion/plugin-sdk";
import plugin from "../index.js";
import type { AgentRuntime } from "../types.js";
const {
mockResolveModelConfig,
mockCreateStreamSession,
mockStreamPrompt,
mockDescribeStreamModel,
} = vi.hoisted(() => ({
mockResolveModelConfig: vi.fn().mockReturnValue({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
apiKey: undefined,
thinkingLevel: undefined,
}),
mockCreateStreamSession: vi.fn().mockReturnValue({ messages: [], dispose: vi.fn() }),
mockStreamPrompt: vi.fn().mockResolvedValue(undefined),
mockDescribeStreamModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
}));
vi.mock("../pi-module.js", () => ({
resolveModelConfig: mockResolveModelConfig,
createStreamSession: mockCreateStreamSession,
streamPrompt: mockStreamPrompt,
describeStreamModel: mockDescribeStreamModel,
}));
function isAgentRuntime(value: unknown): value is AgentRuntime {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as AgentRuntime).createSession === "function" &&
typeof (value as AgentRuntime).promptWithFallback === "function" &&
typeof (value as AgentRuntime).describeModel === "function"
);
}
function createMockContext() {
return {
pluginId: "fusion-plugin-hermes-runtime",
settings: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
emitEvent: vi.fn(),
taskStore: { getTask: vi.fn() },
};
}
describe("Hermes runtime plugin integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("exports a valid Fusion plugin manifest", () => {
const fusionPlugin = plugin as FusionPlugin;
expect(fusionPlugin).toBeDefined();
expect(fusionPlugin.manifest.id).toBe("fusion-plugin-hermes-runtime");
});
it("runtime factory returns an AgentRuntime-compatible Hermes adapter", async () => {
const runtime = (await plugin.runtime!.factory(createMockContext() as any)) as AgentRuntime;
expect(runtime.id).toBe("hermes");
expect(runtime.name).toBe("Hermes Runtime");
expect(isAgentRuntime(runtime)).toBe(true);
const created = await runtime.createSession({ cwd: "/tmp", systemPrompt: "helpful" });
expect(created.sessionFile).toBeUndefined();
await runtime.promptWithFallback(created.session, "Hello integration");
expect(mockStreamPrompt).toHaveBeenCalled();
expect(runtime.describeModel(created.session)).toBe("anthropic/claude-sonnet-4-5");
});
it("onLoad emits hermes-runtime:loaded with runtime metadata", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx as any);
expect(mockResolveModelConfig).toHaveBeenCalledWith(ctx.settings);
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
runtimeId: "hermes",
version: "0.1.0",
});
});
});

View File

@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
// ── Mock node:child_process ────────────────────────────────────────────────
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
import { probeHermesBinary } from "../probe.js";
// ── Helpers ────────────────────────────────────────────────────────────────
/** Yields to the microtask and I/O queue so awaited code can continue. */
function flushAsync(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
function makeFakeChild(): {
child: ChildProcess;
emitStdout: (data: string) => void;
emitStderr: (data: string) => void;
emitError: (err: NodeJS.ErrnoException) => void;
emitClose: (code: number | null) => void;
} {
const main = new EventEmitter() as ChildProcess;
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
(main as any).stdout = stdoutEmitter;
(main as any).stderr = stderrEmitter;
(main as any).kill = vi.fn();
return {
child: main,
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
emitError: (err) => main.emit("error", err),
emitClose: (code) => main.emit("close", code),
};
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe("probeHermesBinary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns available: false with not-found reason on ENOENT", async () => {
// probeHermesBinary first awaits tryResolveBinaryPath (which/where spawn),
// then spawns the --version process. We must drive them sequentially.
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child) // which hermes
.mockReturnValueOnce(versionChild.child); // hermes --version
const promise = probeHermesBinary({ timeoutMs: 500 });
// Settle the `which` call — which causes tryResolveBinaryPath to resolve.
whichChild.emitClose(1);
// Yield so the awaited tryResolveBinaryPath continuation runs and spawns
// the version child before we emit its error.
await flushAsync();
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
versionChild.emitError(enoent);
const result = await promise;
expect(result.available).toBe(false);
expect(result.reason).toMatch(/not found on PATH/);
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("returns available: true with parsed version on success", async () => {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child)
.mockReturnValueOnce(versionChild.child);
const promise = probeHermesBinary({ binaryPath: "hermes", timeoutMs: 500 });
whichChild.emitStdout("/usr/local/bin/hermes\n");
whichChild.emitClose(0);
await flushAsync();
versionChild.emitStdout("Hermes Agent v1.2.3\n");
versionChild.emitClose(0);
const result = await promise;
expect(result.available).toBe(true);
expect(result.version).toBe("Hermes Agent v1.2.3");
expect(result.binaryPath).toBe("/usr/local/bin/hermes");
expect(result.reason).toBeUndefined();
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("returns available: false when version exits non-zero", async () => {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child)
.mockReturnValueOnce(versionChild.child);
const promise = probeHermesBinary({ timeoutMs: 500 });
whichChild.emitClose(1);
await flushAsync();
versionChild.emitStderr("error: something went wrong");
versionChild.emitClose(2);
const result = await promise;
expect(result.available).toBe(false);
expect(result.reason).toContain("error: something went wrong");
});
it("uses custom binaryPath when provided", async () => {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
mockSpawn
.mockReturnValueOnce(whichChild.child)
.mockReturnValueOnce(versionChild.child);
const promise = probeHermesBinary({ binaryPath: "/opt/bin/hermes", timeoutMs: 500 });
whichChild.emitClose(1);
await flushAsync();
versionChild.emitStdout("Hermes Agent v2.0.0\n");
versionChild.emitClose(0);
const result = await promise;
expect(result.available).toBe(true);
expect(result.version).toBe("Hermes Agent v2.0.0");
// Verify the version spawn used our custom path.
const versionSpawnArgs = mockSpawn.mock.calls[1]!;
expect(versionSpawnArgs[0]).toBe("/opt/bin/hermes");
expect(versionSpawnArgs[1]).toEqual(["--version"]);
});
});

View File

@@ -1,109 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
const {
mockCreateStreamSession,
mockStreamPrompt,
mockDescribeStreamModel,
} = vi.hoisted(() => ({
mockCreateStreamSession: vi.fn(),
mockStreamPrompt: vi.fn(),
mockDescribeStreamModel: vi.fn(),
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock("../pi-module.js", () => ({
createStreamSession: mockCreateStreamSession,
streamPrompt: mockStreamPrompt,
describeStreamModel: mockDescribeStreamModel,
}));
vi.mock("../cli-spawn.js", async () => {
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
"../cli-spawn.js",
);
return {
...actual,
invokeHermesCli: mockInvoke,
};
});
describe("HermesRuntimeAdapter", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
mockInvoke.mockResolvedValue({
body: "hello from hermes",
sessionId: "20260427_120000_abc123",
});
});
it("has stable runtime identity", () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
describe("HermesRuntimeAdapter — identity", () => {
it("has stable id/name", () => {
const adapter = new HermesRuntimeAdapter({});
expect(adapter.id).toBe("hermes");
expect(adapter.name).toBe("Hermes Runtime");
});
});
it("createSession passes model config/systemPrompt/callbacks and returns undefined sessionFile", async () => {
const adapter = new HermesRuntimeAdapter({
provider: "openai",
modelId: "gpt-5",
apiKey: "secret",
thinkingLevel: "high",
});
const session = { messages: [], dispose: vi.fn() };
mockCreateStreamSession.mockReturnValue(session);
describe("HermesRuntimeAdapter — createSession", () => {
it("returns a session with empty sessionId and undefined sessionFile", async () => {
const adapter = new HermesRuntimeAdapter({});
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const result = await adapter.createSession({
cwd: "/tmp/project",
systemPrompt: "You are Hermes",
tools: "coding",
customTools: [{ name: "ignored" }],
sessionManager: { foo: "bar" },
skillSelection: { all: true },
skills: ["bash"],
cwd: "/repo",
systemPrompt: "be helpful",
onText,
onThinking,
onToolStart,
onToolEnd,
});
expect(mockCreateStreamSession).toHaveBeenCalledWith({
provider: "openai",
modelId: "gpt-5",
apiKey: "secret",
thinkingLevel: "high",
systemPrompt: "You are Hermes",
callbacks: {
onText,
onThinking,
onToolStart,
onToolEnd,
},
});
expect(result).toEqual({ session, sessionFile: undefined });
expect(JSON.stringify(mockCreateStreamSession.mock.calls[0][0])).not.toContain("/tmp/project");
expect(JSON.stringify(mockCreateStreamSession.mock.calls[0][0])).not.toContain("coding");
});
it("promptWithFallback appends user message then delegates to streamPrompt", async () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
const session = { messages: [], dispose: vi.fn() } as any;
await adapter.promptWithFallback(session, "Hello from Hermes");
expect(session.messages).toEqual([{ role: "user", content: "Hello from Hermes" }]);
expect(mockStreamPrompt).toHaveBeenCalledWith(session, {
role: "user",
content: "Hello from Hermes",
});
});
it("describeModel delegates to describeStreamModel", () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
const session = { messages: [], dispose: vi.fn() } as any;
mockDescribeStreamModel.mockReturnValue("anthropic/claude-sonnet-4-5");
expect(adapter.describeModel(session)).toBe("anthropic/claude-sonnet-4-5");
expect(mockDescribeStreamModel).toHaveBeenCalledWith(session);
});
it("dispose is a no-op when missing and calls dispose when present", async () => {
const adapter = new HermesRuntimeAdapter({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
const dispose = vi.fn();
await expect(adapter.dispose({ messages: [], dispose } as any)).resolves.toBeUndefined();
await expect(adapter.dispose({ messages: [] } as any)).resolves.toBeUndefined();
expect(dispose).toHaveBeenCalledTimes(1);
expect(result.sessionFile).toBeUndefined();
expect(result.session.sessionId).toBe("");
expect(result.session.systemPrompt).toBe("be helpful");
expect(result.session.callbacks.onText).toBe(onText);
});
});
describe("HermesRuntimeAdapter — promptWithFallback", () => {
it("invokes hermes CLI with no resume on first call", async () => {
const adapter = new HermesRuntimeAdapter({ model: "claude-sonnet-4-5" });
const onText = vi.fn();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
onText,
});
await adapter.promptWithFallback(session, "first prompt");
expect(mockInvoke).toHaveBeenCalledTimes(1);
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0];
expect(prompt).toBe("first prompt");
expect(settings.model).toBe("claude-sonnet-4-5");
expect(resumeId).toBeUndefined();
expect(onText).toHaveBeenCalledWith("hello from hermes");
// Session id is captured for next call
expect(session.sessionId).toBe("20260427_120000_abc123");
});
it("passes captured session id as --resume on subsequent calls", async () => {
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await adapter.promptWithFallback(session, "p1");
await adapter.promptWithFallback(session, "p2");
const [, , resume2] = mockInvoke.mock.calls[1];
expect(resume2).toBe("20260427_120000_abc123");
});
it("propagates CLI errors", async () => {
mockInvoke.mockRejectedValueOnce(new Error("hermes: missing session_id"));
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await expect(adapter.promptWithFallback(session, "p")).rejects.toThrow(
/missing session_id/,
);
});
it("does NOT call onText when body is empty", async () => {
mockInvoke.mockResolvedValueOnce({
body: "",
sessionId: "20260427_120000_def456",
});
const adapter = new HermesRuntimeAdapter({});
const onText = vi.fn();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
onText,
});
await adapter.promptWithFallback(session, "p");
expect(onText).not.toHaveBeenCalled();
});
});
describe("HermesRuntimeAdapter — describeModel", () => {
it("returns hermes/<provider>/<model> when both set", async () => {
const adapter = new HermesRuntimeAdapter({
provider: "anthropic",
model: "claude-sonnet-4-5",
});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe(
"hermes/anthropic/claude-sonnet-4-5",
);
});
it("returns hermes/<model> when only model set", async () => {
const adapter = new HermesRuntimeAdapter({ model: "MiniMax-M2.7" });
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe("hermes/MiniMax-M2.7");
});
it("returns plain 'hermes' when neither set", async () => {
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe("hermes");
});
});
describe("HermesRuntimeAdapter — dispose", () => {
it("is a no-op", async () => {
const adapter = new HermesRuntimeAdapter({});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await expect(adapter.dispose!(session)).resolves.toBeUndefined();
});
});