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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
156
plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts
Normal file
156
plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user