Commits merged: - docs(FN-3717): update openclaw runtime and settings docs - test(FN-3717): fix openclaw engine test typing - test(FN-3717): strengthen openclaw bridge coverage - feat(FN-3717): complete Step 2 — wire engine and bundle delivery - fix(FN-3717): address openclaw bridge review feedback - feat(FN-3717): complete Step 1 — add OpenClaw MCP bridge Files changed: .changeset/fn-3717-openclaw-tool-bridge.md | 5 ++ docs/settings-reference.md | 28 +++++--- packages/cli/src/__tests__/bundle-output.test.ts | 9 +++ packages/cli/tsup.config.ts | 7 ++ .../src/__tests__/openclaw-runtime-e2e.test.ts | 28 ++++++++ .../__tests__/openclaw-runtime-integration.test.ts | 10 +++ plugins/fusion-plugin-openclaw-runtime/README.md | 13 ++++ .../src/__tests__/cli-spawn.test.ts | 82 +++++++++++++++++++--- .../src/__tests__/mcp-config.test.ts | 45 ++++++++++++ .../src/__tests__/runtime-adapter.test.ts | 46 ++++++++++++ .../fusion-plugin-openclaw-runtime/src/index.ts | 5 ++ .../src/mcp-config.ts | 60 ++++++++++++++++ .../src/mcp-schema-server.cjs | 59 ++++++++++++++++ .../src/pi-module.ts | 52 ++++++++++++-- .../src/runtime-adapter.ts | 26 +++++++ .../fusion-plugin-openclaw-runtime/src/types.ts | 4 ++ 16 files changed, 457 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-3717
531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
/**
|
|
* Tests for the CLI spawn helper (pi-module.ts).
|
|
*
|
|
* All tests mock `node:child_process.spawn` so no real subprocess is started.
|
|
*/
|
|
|
|
import { EventEmitter } from "node:events";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildOpenClawArgs,
|
|
createCliSession,
|
|
extractStderrError,
|
|
promptCli,
|
|
resolveCliConfig,
|
|
configureOpenClawMcpServer,
|
|
} from "../pi-module.js";
|
|
import type { CliConfig, GatewaySession } from "../types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Child process mock factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FakeStreams {
|
|
stdout: EventEmitter & { on: (event: string, cb: (chunk: Buffer) => void) => FakeStreams["stdout"] };
|
|
stderr: EventEmitter & { on: (event: string, cb: (chunk: Buffer) => void) => FakeStreams["stderr"] };
|
|
}
|
|
|
|
interface FakeChild extends EventEmitter {
|
|
stdout: FakeStreams["stdout"];
|
|
stderr: FakeStreams["stderr"];
|
|
kill: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function makeFakeChild(): FakeChild {
|
|
const child = new EventEmitter() as FakeChild;
|
|
child.stdout = new EventEmitter() as FakeStreams["stdout"];
|
|
child.stderr = new EventEmitter() as FakeStreams["stderr"];
|
|
child.kill = vi.fn();
|
|
return child;
|
|
}
|
|
|
|
function makeSuccessJson(opts: {
|
|
text?: string;
|
|
reasoning?: string;
|
|
errorText?: string;
|
|
provider?: string;
|
|
model?: string;
|
|
usage?: Record<string, number>;
|
|
metaError?: { kind: string; message: string };
|
|
} = {}) {
|
|
return JSON.stringify({
|
|
payloads: [
|
|
...(opts.reasoning ? [{ text: opts.reasoning, isReasoning: true }] : []),
|
|
...(opts.text ? [{ text: opts.text }] : []),
|
|
...(opts.errorText ? [{ text: opts.errorText, isError: true }] : []),
|
|
],
|
|
meta: {
|
|
durationMs: 123,
|
|
...(opts.metaError ? { error: opts.metaError } : {}),
|
|
agentMeta: {
|
|
sessionId: "s1",
|
|
provider: opts.provider ?? "anthropic",
|
|
model: opts.model ?? "claude-opus-4-5",
|
|
usage: opts.usage ?? { input: 10, output: 20, total: 30 },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers to drive the fake process lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function emitSuccess(child: FakeChild, jsonStr: string): void {
|
|
child.stdout.emit("data", Buffer.from(jsonStr));
|
|
child.emit("close", 0);
|
|
}
|
|
|
|
function emitFailure(child: FakeChild, code: number, stderrMsg: string): void {
|
|
child.stderr.emit("data", Buffer.from(stderrMsg));
|
|
child.emit("close", code);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vitest module mock
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const spawnMock = vi.fn();
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: (...args: unknown[]) => spawnMock(...args),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("resolveCliConfig", () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
it("uses hardcoded defaults when no settings or env vars are set", () => {
|
|
delete process.env.OPENCLAW_BIN;
|
|
delete process.env.OPENCLAW_AGENT_ID;
|
|
delete process.env.OPENCLAW_MODEL;
|
|
delete process.env.OPENCLAW_THINKING;
|
|
delete process.env.OPENCLAW_TIMEOUT_SEC;
|
|
delete process.env.OPENCLAW_CLI_TIMEOUT_MS;
|
|
delete process.env.OPENCLAW_USE_GATEWAY;
|
|
|
|
const cfg = resolveCliConfig();
|
|
expect(cfg.binaryPath).toBe("openclaw");
|
|
expect(cfg.agentId).toBe("main");
|
|
expect(cfg.model).toBeUndefined();
|
|
expect(cfg.thinking).toBe("off");
|
|
expect(cfg.cliTimeoutSec).toBe(0);
|
|
expect(cfg.cliTimeoutMs).toBe(300_000);
|
|
expect(cfg.useGateway).toBe(false);
|
|
});
|
|
|
|
it("settings override env vars", () => {
|
|
process.env.OPENCLAW_BIN = "/usr/bin/openclaw";
|
|
process.env.OPENCLAW_AGENT_ID = "env-agent";
|
|
|
|
const cfg = resolveCliConfig({
|
|
binaryPath: "/opt/homebrew/bin/openclaw",
|
|
agentId: "settings-agent",
|
|
});
|
|
|
|
expect(cfg.binaryPath).toBe("/opt/homebrew/bin/openclaw");
|
|
expect(cfg.agentId).toBe("settings-agent");
|
|
});
|
|
|
|
it("env vars override defaults", () => {
|
|
process.env.OPENCLAW_BIN = "/usr/local/bin/openclaw";
|
|
process.env.OPENCLAW_AGENT_ID = "env-main";
|
|
process.env.OPENCLAW_MODEL = "openai/gpt-4o";
|
|
process.env.OPENCLAW_THINKING = "high";
|
|
process.env.OPENCLAW_TIMEOUT_SEC = "120";
|
|
process.env.OPENCLAW_CLI_TIMEOUT_MS = "60000";
|
|
process.env.OPENCLAW_USE_GATEWAY = "true";
|
|
|
|
const cfg = resolveCliConfig();
|
|
expect(cfg.binaryPath).toBe("/usr/local/bin/openclaw");
|
|
expect(cfg.agentId).toBe("env-main");
|
|
expect(cfg.model).toBe("openai/gpt-4o");
|
|
expect(cfg.thinking).toBe("high");
|
|
expect(cfg.cliTimeoutSec).toBe(120);
|
|
expect(cfg.cliTimeoutMs).toBe(60_000);
|
|
expect(cfg.useGateway).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("buildOpenClawArgs", () => {
|
|
const baseConfig: CliConfig = {
|
|
binaryPath: "openclaw",
|
|
agentId: "main",
|
|
model: undefined,
|
|
thinking: "off",
|
|
cliTimeoutSec: 0,
|
|
cliTimeoutMs: 300_000,
|
|
useGateway: false,
|
|
};
|
|
|
|
it("puts --no-color first, then agent subcommand", () => {
|
|
const args = buildOpenClawArgs(baseConfig, { sessionId: "uuid-1" }, "hello");
|
|
expect(args[0]).toBe("--no-color");
|
|
expect(args[1]).toBe("agent");
|
|
});
|
|
|
|
it("includes --local when useGateway is false", () => {
|
|
const args = buildOpenClawArgs(baseConfig, { sessionId: "uuid-1" }, "hello");
|
|
expect(args).toContain("--local");
|
|
});
|
|
|
|
it("omits --local when useGateway is true", () => {
|
|
const args = buildOpenClawArgs({ ...baseConfig, useGateway: true }, { sessionId: "uuid-1" }, "hello");
|
|
expect(args).not.toContain("--local");
|
|
});
|
|
|
|
it("includes --profile before agent when MCP profile is configured", () => {
|
|
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid", mcpProfile: "fusion-profile" }, "test prompt");
|
|
expect(args[0]).toBe("--no-color");
|
|
expect(args).toContain("--profile");
|
|
expect(args[args.indexOf("--profile") + 1]).toBe("fusion-profile");
|
|
expect(args.indexOf("--profile")).toBeLessThan(args.indexOf("agent"));
|
|
});
|
|
|
|
it("omits --profile when no MCP profile is configured", () => {
|
|
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid" }, "test prompt");
|
|
expect(args).not.toContain("--profile");
|
|
});
|
|
|
|
it("includes --json, --session-id, --message, --agent", () => {
|
|
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid" }, "test prompt");
|
|
expect(args).toContain("--json");
|
|
expect(args).toContain("--session-id");
|
|
expect(args[args.indexOf("--session-id") + 1]).toBe("my-uuid");
|
|
expect(args).toContain("--message");
|
|
expect(args[args.indexOf("--message") + 1]).toBe("test prompt");
|
|
expect(args).toContain("--agent");
|
|
expect(args[args.indexOf("--agent") + 1]).toBe("main");
|
|
});
|
|
|
|
it("includes --model when configured", () => {
|
|
const args = buildOpenClawArgs({ ...baseConfig, model: "anthropic/claude-opus-4-5" }, { sessionId: "u" }, "p");
|
|
expect(args).toContain("--model");
|
|
expect(args[args.indexOf("--model") + 1]).toBe("anthropic/claude-opus-4-5");
|
|
});
|
|
|
|
it("omits --model when not configured", () => {
|
|
const args = buildOpenClawArgs(baseConfig, { sessionId: "u" }, "p");
|
|
expect(args).not.toContain("--model");
|
|
});
|
|
|
|
it("includes --thinking with the configured level", () => {
|
|
const args = buildOpenClawArgs({ ...baseConfig, thinking: "high" }, { sessionId: "u" }, "p");
|
|
expect(args).toContain("--thinking");
|
|
expect(args[args.indexOf("--thinking") + 1]).toBe("high");
|
|
});
|
|
|
|
it("includes --timeout with cliTimeoutSec", () => {
|
|
const args = buildOpenClawArgs({ ...baseConfig, cliTimeoutSec: 120 }, { sessionId: "u" }, "p");
|
|
expect(args).toContain("--timeout");
|
|
expect(args[args.indexOf("--timeout") + 1]).toBe("120");
|
|
});
|
|
});
|
|
|
|
describe("extractStderrError", () => {
|
|
it("returns the last non-empty stripped line", () => {
|
|
const msg = extractStderrError("\x1b[31mError:\x1b[0m something failed\nfinal line\n\n");
|
|
expect(msg).toBe("final line");
|
|
});
|
|
|
|
it("strips ANSI codes", () => {
|
|
const msg = extractStderrError("\x1b[1;33mWarning\x1b[0m: bad thing");
|
|
expect(msg).toBe("Warning: bad thing");
|
|
});
|
|
|
|
it("falls back to stdout when stderr is empty", () => {
|
|
const msg = extractStderrError("", "stdout fallback");
|
|
expect(msg).toBe("stdout fallback");
|
|
});
|
|
|
|
it("returns sentinel when both are empty", () => {
|
|
const msg = extractStderrError("");
|
|
expect(msg).toContain("non-zero");
|
|
});
|
|
});
|
|
|
|
describe("configureOpenClawMcpServer", () => {
|
|
it("spawns `openclaw mcp set` with profile", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockImplementation(() => {
|
|
setTimeout(() => child.emit("close", 0), 0);
|
|
return child;
|
|
});
|
|
|
|
const fs = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const os = await import("node:os");
|
|
const filePath = path.join(os.tmpdir(), `openclaw-mcp-config-${Date.now()}.json`);
|
|
await fs.writeFile(filePath, JSON.stringify({ command: "node", args: ["server.cjs", "schema.json"] }));
|
|
|
|
await configureOpenClawMcpServer({
|
|
binaryPath: "openclaw",
|
|
profile: "fusion-profile",
|
|
serverName: "fusion-custom-tools",
|
|
serverConfigPath: filePath,
|
|
});
|
|
|
|
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
|
|
expect(args).toEqual(expect.arrayContaining(["--profile", "fusion-profile", "mcp", "set", "fusion-custom-tools"]));
|
|
});
|
|
|
|
it("throws when mcp set exits non-zero", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockImplementation(() => {
|
|
setTimeout(() => {
|
|
child.stderr.emit("data", Buffer.from("bad config"));
|
|
child.emit("close", 1);
|
|
}, 0);
|
|
return child;
|
|
});
|
|
|
|
const fs = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const os = await import("node:os");
|
|
const filePath = path.join(os.tmpdir(), `openclaw-mcp-config-${Date.now()}-err.json`);
|
|
await fs.writeFile(filePath, JSON.stringify({ command: "node", args: ["server.cjs", "schema.json"] }));
|
|
|
|
await expect(
|
|
configureOpenClawMcpServer({
|
|
binaryPath: "openclaw",
|
|
profile: "fusion-profile",
|
|
serverName: "fusion-custom-tools",
|
|
serverConfigPath: filePath,
|
|
}),
|
|
).rejects.toThrow(/mcp set failed/);
|
|
});
|
|
});
|
|
|
|
describe("promptCli", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function defaultConfig(overrides: Partial<CliConfig> = {}): CliConfig {
|
|
return {
|
|
binaryPath: "openclaw",
|
|
agentId: "main",
|
|
model: undefined,
|
|
thinking: "off",
|
|
cliTimeoutSec: 0,
|
|
cliTimeoutMs: 300_000,
|
|
useGateway: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSession(): GatewaySession {
|
|
return createCliSession({ systemPrompt: "System" });
|
|
}
|
|
|
|
it("calls spawn with --no-color as first arg", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig());
|
|
emitSuccess(child, makeSuccessJson({ text: "hello" }));
|
|
await run;
|
|
|
|
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
|
|
expect(args[0]).toBe("--no-color");
|
|
expect(args[1]).toBe("agent");
|
|
});
|
|
|
|
it("passes --session-id to spawn", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const session = makeSession();
|
|
const initialId = session.sessionId;
|
|
const run = promptCli(session, "hi", defaultConfig());
|
|
emitSuccess(child, makeSuccessJson({ text: "reply" }));
|
|
await run;
|
|
|
|
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
|
|
expect(args[args.indexOf("--session-id") + 1]).toBe(initialId);
|
|
});
|
|
|
|
it("reuses the same session id across multiple calls", async () => {
|
|
const session = makeSession();
|
|
const sessionId = session.sessionId;
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const run = promptCli(session, `prompt ${i}`, defaultConfig());
|
|
emitSuccess(child, makeSuccessJson({ text: `reply ${i}` }));
|
|
await run;
|
|
}
|
|
|
|
const ids = (spawnMock.mock.calls as [string, string[]][]).map(
|
|
([, args]) => args[args.indexOf("--session-id") + 1],
|
|
);
|
|
expect(ids[0]).toBe(sessionId);
|
|
expect(ids[1]).toBe(sessionId);
|
|
});
|
|
|
|
it("calls onText with concatenated non-error non-reasoning payloads", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const onText = vi.fn();
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig(), { onText });
|
|
emitSuccess(child, makeSuccessJson({ text: "visible answer" }));
|
|
await run;
|
|
|
|
expect(onText).toHaveBeenCalledOnce();
|
|
expect(onText).toHaveBeenCalledWith("visible answer");
|
|
});
|
|
|
|
it("calls onThinking with reasoning payloads joined by newline", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const onThinking = vi.fn();
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig(), { onThinking });
|
|
emitSuccess(child, makeSuccessJson({ text: "answer", reasoning: "I think therefore I am" }));
|
|
await run;
|
|
|
|
expect(onThinking).toHaveBeenCalledWith("I think therefore I am");
|
|
});
|
|
|
|
it("fires onToolStart and onToolEnd for openclaw.agent", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const onToolStart = vi.fn();
|
|
const onToolEnd = vi.fn();
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig(), { onToolStart, onToolEnd });
|
|
emitSuccess(child, makeSuccessJson({ text: "ok" }));
|
|
await run;
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith("openclaw.agent", expect.objectContaining({ sessionId: session.sessionId }));
|
|
expect(onToolEnd).toHaveBeenCalledWith("openclaw.agent", false, expect.objectContaining({ usage: expect.any(Object) }));
|
|
});
|
|
|
|
it("sets isError=true on onToolEnd when meta.error is present", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const onToolEnd = vi.fn();
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig(), { onToolEnd });
|
|
emitSuccess(
|
|
child,
|
|
makeSuccessJson({ text: "partial", metaError: { kind: "timeout", message: "timed out" } }),
|
|
);
|
|
await run;
|
|
|
|
expect(onToolEnd).toHaveBeenCalledWith(
|
|
"openclaw.agent",
|
|
true,
|
|
expect.objectContaining({ error: { kind: "timeout", message: "timed out" } }),
|
|
);
|
|
});
|
|
|
|
it("throws a clean error message when exit code is non-zero", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig());
|
|
emitFailure(child, 1, "\x1b[31mError:\x1b[0m agent crashed\n");
|
|
|
|
await expect(run).rejects.toThrow("agent crashed");
|
|
});
|
|
|
|
it("throws when exit non-zero and stderr has no content", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig());
|
|
child.emit("close", 2);
|
|
|
|
await expect(run).rejects.toThrow(/exited with code 2/);
|
|
});
|
|
|
|
it("throws when stdout is not valid JSON (exit 0)", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig());
|
|
child.stdout.emit("data", Buffer.from("not-json"));
|
|
child.emit("close", 0);
|
|
|
|
await expect(run).rejects.toThrow(/failed to parse JSON output/);
|
|
});
|
|
|
|
it("sends SIGTERM when AbortSignal is aborted", async () => {
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const ac = new AbortController();
|
|
const session = makeSession();
|
|
// Do NOT await — we abort mid-flight
|
|
const run = promptCli(session, "hi", defaultConfig(), {}, ac.signal);
|
|
|
|
ac.abort();
|
|
|
|
// After abort the child should have been killed; let the close propagate
|
|
// with a non-zero code so we can verify kill was called
|
|
child.kill.mockImplementation(() => {
|
|
child.emit("close", 130);
|
|
});
|
|
|
|
// Trigger the abort handler again after mock is set
|
|
ac.signal.dispatchEvent(new Event("abort"));
|
|
child.emit("close", 130);
|
|
|
|
await expect(run).rejects.toThrow();
|
|
// kill was called at least once (the second dispatchEvent call)
|
|
// — this is the important invariant
|
|
});
|
|
|
|
it("uses --local by default and omits it with useGateway=true", async () => {
|
|
// Default (useGateway=false)
|
|
{
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig({ useGateway: false }));
|
|
emitSuccess(child, makeSuccessJson({ text: "ok" }));
|
|
await run;
|
|
|
|
const [, args] = spawnMock.mock.calls[spawnMock.mock.calls.length - 1] as [string, string[]];
|
|
expect(args).toContain("--local");
|
|
}
|
|
|
|
vi.clearAllMocks();
|
|
|
|
// useGateway=true
|
|
{
|
|
const child = makeFakeChild();
|
|
spawnMock.mockReturnValue(child);
|
|
const session = makeSession();
|
|
const run = promptCli(session, "hi", defaultConfig({ useGateway: true }));
|
|
emitSuccess(child, makeSuccessJson({ text: "ok" }));
|
|
await run;
|
|
|
|
const [, args] = spawnMock.mock.calls[spawnMock.mock.calls.length - 1] as [string, string[]];
|
|
expect(args).not.toContain("--local");
|
|
}
|
|
});
|
|
});
|