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,464 @@
|
||||
/**
|
||||
* 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,
|
||||
} 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, "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, "uuid-1", "hello");
|
||||
expect(args).toContain("--local");
|
||||
});
|
||||
|
||||
it("omits --local when useGateway is true", () => {
|
||||
const args = buildOpenClawArgs({ ...baseConfig, useGateway: true }, "uuid-1", "hello");
|
||||
expect(args).not.toContain("--local");
|
||||
});
|
||||
|
||||
it("includes --json, --session-id, --message, --agent", () => {
|
||||
const args = buildOpenClawArgs(baseConfig, "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" }, "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, "u", "p");
|
||||
expect(args).not.toContain("--model");
|
||||
});
|
||||
|
||||
it("includes --thinking with the configured level", () => {
|
||||
const args = buildOpenClawArgs({ ...baseConfig, thinking: "high" }, "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 }, "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("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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createGatewaySession,
|
||||
probeGateway,
|
||||
promptGateway,
|
||||
resolveGatewayConfig,
|
||||
} from "../pi-module.js";
|
||||
|
||||
function createSseResponse(events: string[], init?: ResponseInit): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const event of events) {
|
||||
controller.enqueue(encoder.encode(event));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway client", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("createGatewaySession includes a no-op dispose handler", () => {
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "token",
|
||||
agentId: "main",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
|
||||
expect(typeof session.dispose).toBe("function");
|
||||
expect(() => session.dispose?.()).not.toThrow();
|
||||
});
|
||||
|
||||
it("resolves config from settings first, then env, then defaults", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "http://env-gateway:18789";
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
|
||||
process.env.OPENCLAW_AGENT_ID = "env-agent";
|
||||
|
||||
expect(
|
||||
resolveGatewayConfig({
|
||||
gatewayUrl: "http://settings-gateway:18789",
|
||||
gatewayToken: "settings-token",
|
||||
agentId: "settings-agent",
|
||||
}),
|
||||
).toEqual({
|
||||
gatewayUrl: "http://settings-gateway:18789",
|
||||
gatewayToken: "settings-token",
|
||||
agentId: "settings-agent",
|
||||
});
|
||||
|
||||
expect(resolveGatewayConfig({})).toEqual({
|
||||
gatewayUrl: "http://env-gateway:18789",
|
||||
gatewayToken: "env-token",
|
||||
agentId: "env-agent",
|
||||
});
|
||||
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_AGENT_ID;
|
||||
|
||||
expect(resolveGatewayConfig({})).toEqual({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: undefined,
|
||||
agentId: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("probeGateway returns true for any reachable HTTP response and false on network failures", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not found", { status: 404 })));
|
||||
await expect(probeGateway("http://127.0.0.1:18789")).resolves.toBe(true);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||
await expect(probeGateway("http://127.0.0.1:18789")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("streams text, thinking, and tool-call events from SSE", async () => {
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
createSseResponse([
|
||||
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"reasoning_content":"internal "}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"lookup","arguments":"{\\"id\\":"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"123}"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":"world"}}]}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "secret",
|
||||
agentId: "main",
|
||||
systemPrompt: "You are helpful",
|
||||
});
|
||||
session.messages.push({ role: "user", content: "Say hello" });
|
||||
|
||||
const result = await promptGateway(session, "Say hello", {
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(result).toBe("Hello world");
|
||||
expect(onText).toHaveBeenCalledTimes(2);
|
||||
expect(onText).toHaveBeenNthCalledWith(1, "Hello ");
|
||||
expect(onText).toHaveBeenNthCalledWith(2, "world");
|
||||
expect(onThinking).toHaveBeenCalledWith("internal ");
|
||||
expect(onToolStart).toHaveBeenCalledWith("lookup");
|
||||
expect(onToolEnd).toHaveBeenCalledWith("lookup", false, { id: 123 });
|
||||
|
||||
const [requestUrl, requestInit] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
expect(requestUrl.toString()).toBe("http://127.0.0.1:18789/v1/chat/completions");
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-agent-id": "main",
|
||||
});
|
||||
|
||||
const parsedBody = JSON.parse(String(requestInit.body));
|
||||
expect(parsedBody.model).toBe("openclaw:main");
|
||||
expect(parsedBody.stream).toBe(true);
|
||||
expect(parsedBody.user).toBe(session.sessionId);
|
||||
expect(parsedBody.messages.at(-1)).toEqual({ role: "user", content: "Say hello" });
|
||||
});
|
||||
|
||||
it("handles empty data lines, [DONE], and keeps conversation across calls", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createSseResponse([
|
||||
"data: \n\n",
|
||||
'data: {"choices":[{"delta":{"content":"first"}}]}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSseResponse(['data: {"choices":[{"delta":{"content":" second"}}]}\n\n', "data: [DONE]\n\n"]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
agentId: "main",
|
||||
systemPrompt: "System",
|
||||
});
|
||||
session.messages.push({ role: "user", content: "one" });
|
||||
await promptGateway(session, "one");
|
||||
|
||||
session.messages.push({ role: "user", content: "two" });
|
||||
await promptGateway(session, "two");
|
||||
|
||||
expect(session.messages).toEqual([
|
||||
{ role: "developer", content: "System" },
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "assistant", content: "first" },
|
||||
{ role: "user", content: "two" },
|
||||
{ role: "assistant", content: " second" },
|
||||
]);
|
||||
|
||||
const firstBody = JSON.parse(String((fetchMock.mock.calls[0] as [URL, RequestInit])[1].body));
|
||||
const secondBody = JSON.parse(String((fetchMock.mock.calls[1] as [URL, RequestInit])[1].body));
|
||||
expect(firstBody.messages).toHaveLength(2);
|
||||
expect(secondBody.messages).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("throws descriptive errors for non-200 status, invalid SSE JSON, and connection errors", async () => {
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
agentId: "main",
|
||||
systemPrompt: "System",
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad", { status: 503, statusText: "Service Unavailable" })));
|
||||
await expect(promptGateway(session, "test")).rejects.toThrow(
|
||||
"OpenClaw gateway request failed (503 Service Unavailable): bad",
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createSseResponse(["data: {not-json}\n\n"])));
|
||||
await expect(promptGateway(session, "test")).rejects.toThrow("OpenClaw gateway returned invalid SSE JSON");
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ETIMEDOUT")));
|
||||
await expect(promptGateway(session, "test")).rejects.toThrow("ETIMEDOUT");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const {
|
||||
mockResolveGatewayConfig,
|
||||
mockCreateGatewaySession,
|
||||
mockPromptGateway,
|
||||
mockDescribeGatewayModel,
|
||||
mockProbeGateway,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveGatewayConfig: vi.fn().mockReturnValue({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: undefined,
|
||||
const { mockResolveCliConfig, mockProbeBinary } = vi.hoisted(() => ({
|
||||
mockResolveCliConfig: vi.fn().mockReturnValue({
|
||||
binaryPath: "openclaw",
|
||||
agentId: "main",
|
||||
model: undefined,
|
||||
thinking: "off",
|
||||
cliTimeoutSec: 0,
|
||||
cliTimeoutMs: 300_000,
|
||||
useGateway: false,
|
||||
}),
|
||||
mockProbeBinary: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
binaryPath: "/opt/homebrew/bin/openclaw",
|
||||
version: "OpenClaw 2026.4.26",
|
||||
probeDurationMs: 12,
|
||||
}),
|
||||
mockCreateGatewaySession: vi.fn(),
|
||||
mockPromptGateway: vi.fn(),
|
||||
mockDescribeGatewayModel: vi.fn().mockReturnValue("openclaw/main"),
|
||||
mockProbeGateway: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
resolveGatewayConfig: mockResolveGatewayConfig,
|
||||
createGatewaySession: mockCreateGatewaySession,
|
||||
promptGateway: mockPromptGateway,
|
||||
describeGatewayModel: mockDescribeGatewayModel,
|
||||
probeGateway: mockProbeGateway,
|
||||
}));
|
||||
vi.mock("../pi-module.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../pi-module.js")>(
|
||||
"../pi-module.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveCliConfig: mockResolveCliConfig,
|
||||
};
|
||||
});
|
||||
|
||||
import plugin, { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID } from "../index.js";
|
||||
vi.mock("../probe.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../probe.js")>(
|
||||
"../probe.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
probeOpenClawBinary: mockProbeBinary,
|
||||
};
|
||||
});
|
||||
|
||||
import plugin, {
|
||||
openclawRuntimeMetadata,
|
||||
openclawRuntimeFactory,
|
||||
OPENCLAW_RUNTIME_ID,
|
||||
} from "../index.js";
|
||||
import { OpenClawRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
interface MockLogger {
|
||||
@@ -36,133 +52,98 @@ interface MockLogger {
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface MockContext {
|
||||
pluginId: string;
|
||||
settings: Record<string, unknown>;
|
||||
logger: MockLogger;
|
||||
emitEvent: ReturnType<typeof vi.fn>;
|
||||
taskStore: {
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
function createMockContext(settings: Record<string, unknown> = {}) {
|
||||
const logger: MockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(overrides: Partial<MockContext> = {}): MockContext {
|
||||
return {
|
||||
pluginId: "fusion-plugin-openclaw-runtime",
|
||||
settings: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
settings,
|
||||
logger,
|
||||
emitEvent: vi.fn(),
|
||||
taskStore: {
|
||||
getTask: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
taskStore: { getTask: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("openclaw-runtime plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProbeGateway.mockResolvedValue(true);
|
||||
mockResolveCliConfig.mockReturnValue({
|
||||
binaryPath: "openclaw",
|
||||
agentId: "main",
|
||||
model: undefined,
|
||||
thinking: "off",
|
||||
cliTimeoutSec: 0,
|
||||
cliTimeoutMs: 300_000,
|
||||
useGateway: false,
|
||||
});
|
||||
mockProbeBinary.mockResolvedValue({
|
||||
available: true,
|
||||
binaryPath: "/opt/homebrew/bin/openclaw",
|
||||
version: "OpenClaw 2026.4.26",
|
||||
probeDurationMs: 12,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("plugin manifest identity", () => {
|
||||
it("should have correct manifest fields", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-openclaw-runtime");
|
||||
expect(plugin.manifest.name).toBe("OpenClaw Runtime Plugin");
|
||||
expect(plugin.manifest.version).toBe("0.1.0");
|
||||
expect(plugin.manifest.description).toContain("OpenClaw");
|
||||
expect(plugin.manifest.author).toBe("Fusion Team");
|
||||
expect(plugin.state).toBe("installed");
|
||||
});
|
||||
it("manifest identity is stable", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-openclaw-runtime");
|
||||
expect(plugin.manifest.name).toBe("OpenClaw Runtime Plugin");
|
||||
expect(plugin.state).toBe("installed");
|
||||
expect(plugin.runtime?.metadata.runtimeId).toBe(OPENCLAW_RUNTIME_ID);
|
||||
expect(plugin.manifest.runtime).toEqual(openclawRuntimeMetadata);
|
||||
});
|
||||
|
||||
describe("runtime registration", () => {
|
||||
it("should register openclaw runtime metadata", () => {
|
||||
expect(plugin.runtime).toBeDefined();
|
||||
expect(plugin.runtime?.metadata.runtimeId).toBe(OPENCLAW_RUNTIME_ID);
|
||||
expect(plugin.runtime?.metadata.name).toBe("OpenClaw Runtime");
|
||||
expect(plugin.runtime?.metadata.description).toContain("OpenClaw-backed AI session");
|
||||
expect(plugin.runtime?.metadata.version).toBe("0.1.0");
|
||||
});
|
||||
|
||||
it("should have consistent runtime metadata between export and manifest", () => {
|
||||
expect(plugin.manifest.runtime).toEqual(openclawRuntimeMetadata);
|
||||
expect(plugin.runtime?.metadata).toEqual(openclawRuntimeMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks", () => {
|
||||
it("onLoad should probe gateway, log startup message, and emit loaded event", async () => {
|
||||
const ctx = createMockContext();
|
||||
mockResolveGatewayConfig.mockReturnValue({
|
||||
gatewayUrl: "http://localhost:18789",
|
||||
gatewayToken: "secret-token",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
|
||||
expect(mockProbeGateway).toHaveBeenCalledWith("http://localhost:18789");
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"OpenClaw Runtime Plugin loaded (gateway: http://localhost:18789, reachable: yes)",
|
||||
);
|
||||
expect(ctx.logger.info.mock.calls.join(" ")).not.toContain("secret-token");
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("openclaw-runtime:loaded", {
|
||||
it("onLoad probes binary and logs binary path + version", async () => {
|
||||
const ctx = createMockContext({});
|
||||
await plugin.hooks!.onLoad!(ctx as any);
|
||||
expect(mockProbeBinary).toHaveBeenCalledWith({ binaryPath: "openclaw" });
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("openclaw"),
|
||||
);
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith(
|
||||
"openclaw-runtime:loaded",
|
||||
expect.objectContaining({
|
||||
runtimeId: OPENCLAW_RUNTIME_ID,
|
||||
version: "0.1.0",
|
||||
gatewayUrl: "http://localhost:18789",
|
||||
gatewayReachable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("onUnload should not throw", () => {
|
||||
expect(() => plugin.hooks.onUnload?.()).not.toThrow();
|
||||
});
|
||||
binaryAvailable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("runtime factory behavior", () => {
|
||||
it("should export runtime constants", () => {
|
||||
expect(OPENCLAW_RUNTIME_ID).toBe("openclaw");
|
||||
expect(openclawRuntimeMetadata.runtimeId).toBe("openclaw");
|
||||
expect(typeof openclawRuntimeFactory).toBe("function");
|
||||
it("onLoad logs warning when binary missing", async () => {
|
||||
mockProbeBinary.mockResolvedValueOnce({
|
||||
available: false,
|
||||
probeDurationMs: 5,
|
||||
reason: "`openclaw` not found on PATH",
|
||||
});
|
||||
const ctx = createMockContext({});
|
||||
await plugin.hooks!.onLoad!(ctx as any);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not detected"),
|
||||
);
|
||||
});
|
||||
|
||||
it("runtime factory should return executable runtime adapter", async () => {
|
||||
const runtime = (await openclawRuntimeFactory(
|
||||
createMockContext({
|
||||
settings: {
|
||||
gatewayUrl: "http://settings-gateway:18789",
|
||||
gatewayToken: "plugin-token",
|
||||
agentId: "ops",
|
||||
},
|
||||
}) as any,
|
||||
)) as OpenClawRuntimeAdapter;
|
||||
it("factory returns an OpenClawRuntimeAdapter instance", async () => {
|
||||
const runtime = (await openclawRuntimeFactory(
|
||||
createMockContext({ binaryPath: "/usr/bin/openclaw", agentId: "ops" }) as any,
|
||||
)) as OpenClawRuntimeAdapter;
|
||||
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
|
||||
expect(runtime.id).toBe("openclaw");
|
||||
});
|
||||
|
||||
expect(mockResolveGatewayConfig).toHaveBeenCalledWith({
|
||||
gatewayUrl: "http://settings-gateway:18789",
|
||||
gatewayToken: "plugin-token",
|
||||
agentId: "ops",
|
||||
});
|
||||
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
|
||||
expect(runtime.id).toBe("openclaw");
|
||||
expect(runtime.name).toBe("OpenClaw Runtime");
|
||||
expect(runtime).not.toHaveProperty("status");
|
||||
expect(runtime).not.toHaveProperty("execute");
|
||||
});
|
||||
it("factory creation does not throw with empty settings", async () => {
|
||||
await expect(
|
||||
openclawRuntimeFactory(createMockContext() as any),
|
||||
).resolves.toBeInstanceOf(OpenClawRuntimeAdapter);
|
||||
});
|
||||
|
||||
it("factory creation should not throw", async () => {
|
||||
await expect(openclawRuntimeFactory(createMockContext() as any)).resolves.toBeInstanceOf(
|
||||
OpenClawRuntimeAdapter,
|
||||
);
|
||||
});
|
||||
it("onUnload does not throw", () => {
|
||||
expect(() => plugin.hooks!.onUnload?.()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Tests for the probeOpenClawBinary helper.
|
||||
*
|
||||
* All tests mock `node:child_process.spawn` so no real subprocess is started.
|
||||
*
|
||||
* Execution order within probeOpenClawBinary:
|
||||
* 1. spawn(<which>, [binary]) — resolves the binary path (awaited first)
|
||||
* 2. spawn(binary, ["--version"]) — actual version probe
|
||||
*
|
||||
* Strategy: use a call-index counter inside the mock so each spawn call
|
||||
* returns a pre-built fake child. The `which` child is settled asynchronously
|
||||
* via `setImmediate` so the Promise chain advances before the tests drive the
|
||||
* version child.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { probeOpenClawBinary } from "../probe.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Child process mock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeChild extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeFakeChild(): FakeChild {
|
||||
const c = new EventEmitter() as FakeChild;
|
||||
c.stdout = new EventEmitter();
|
||||
c.stderr = new EventEmitter();
|
||||
c.kill = vi.fn();
|
||||
return c;
|
||||
}
|
||||
|
||||
const spawnMock = vi.fn();
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: (...args: unknown[]) => spawnMock(...args),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: settle the `which` child, then yield until the version probe has
|
||||
// been created, then return the version child for the test to drive.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set up the spawn mock to return pre-created fake children by call order.
|
||||
* The `which` child is auto-settled inside the mock so by the time the
|
||||
* probeOpenClawBinary Promise is waiting on the version spawn, it is already
|
||||
* registered in spawnMock.mock.calls.
|
||||
*
|
||||
* Returns the version-child for callers to drive.
|
||||
*/
|
||||
function setupSpawnPair(whichStdout: string, whichCode: number): FakeChild {
|
||||
const whichChild = makeFakeChild();
|
||||
const versionChild = makeFakeChild();
|
||||
let callIndex = 0;
|
||||
|
||||
spawnMock.mockImplementation(() => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
// `which`/`where` call — auto-settle after current micro-task queue drains
|
||||
setImmediate(() => {
|
||||
if (whichStdout) {
|
||||
whichChild.stdout.emit("data", Buffer.from(whichStdout));
|
||||
}
|
||||
whichChild.emit("close", whichCode);
|
||||
});
|
||||
return whichChild;
|
||||
}
|
||||
return versionChild;
|
||||
});
|
||||
|
||||
return versionChild;
|
||||
}
|
||||
|
||||
/** Wait for the version probe spawn to be registered (call index 2). */
|
||||
async function waitForVersionSpawn(): Promise<void> {
|
||||
// The `which` close fires via setImmediate; we need to give Node's event
|
||||
// loop at least one full round-trip so the Promise chain inside
|
||||
// tryResolveBinaryPath resolves and probeOpenClawBinary proceeds to
|
||||
// spawn the version check.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("probeOpenClawBinary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns available=true when the binary exits 0 with version output", async () => {
|
||||
const versionChild = setupSpawnPair("/opt/homebrew/bin/openclaw\n", 0);
|
||||
|
||||
const probePromise = probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 2000 });
|
||||
|
||||
await waitForVersionSpawn();
|
||||
|
||||
versionChild.stdout.emit("data", Buffer.from("OpenClaw 2026.4.26 (be8c246)\n"));
|
||||
versionChild.emit("close", 0);
|
||||
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe("OpenClaw 2026.4.26 (be8c246)");
|
||||
expect(result.binaryPath).toBe("/opt/homebrew/bin/openclaw");
|
||||
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns available=false with ENOENT reason when binary is not found", async () => {
|
||||
const versionChild = setupSpawnPair("", 1);
|
||||
|
||||
const probePromise = probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 2000 });
|
||||
|
||||
await waitForVersionSpawn();
|
||||
|
||||
const enoentError = Object.assign(new Error("spawn openclaw ENOENT"), { code: "ENOENT" });
|
||||
versionChild.emit("error", enoentError);
|
||||
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toContain("not found on PATH");
|
||||
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns available=false with the stderr reason when exit code is non-zero", async () => {
|
||||
const versionChild = setupSpawnPair("", 1);
|
||||
|
||||
const probePromise = probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 2000 });
|
||||
|
||||
await waitForVersionSpawn();
|
||||
|
||||
versionChild.stderr.emit("data", Buffer.from("command not recognized\n"));
|
||||
versionChild.emit("close", 1);
|
||||
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toContain("command not recognized");
|
||||
});
|
||||
|
||||
it("returns available=false with a timeout reason when the process hangs", async () => {
|
||||
// Very short timeout; the version child never emits close
|
||||
setupSpawnPair("", 1);
|
||||
|
||||
const result = await probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 30 });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toContain("timed out");
|
||||
});
|
||||
|
||||
it("uses the custom binaryPath when spawning the version check", async () => {
|
||||
const versionChild = setupSpawnPair("", 1);
|
||||
|
||||
const probePromise = probeOpenClawBinary({
|
||||
binaryPath: "/opt/homebrew/bin/openclaw",
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
|
||||
await waitForVersionSpawn();
|
||||
|
||||
versionChild.stdout.emit("data", Buffer.from("OpenClaw 2026.4.26 (be8c246)\n"));
|
||||
versionChild.emit("close", 0);
|
||||
|
||||
await probePromise;
|
||||
|
||||
// Second spawn call must use the custom path
|
||||
const versionSpawnArgs = spawnMock.mock.calls[1] as [string, string[]];
|
||||
expect(versionSpawnArgs[0]).toBe("/opt/homebrew/bin/openclaw");
|
||||
expect(versionSpawnArgs[1]).toContain("--version");
|
||||
});
|
||||
});
|
||||
@@ -2,115 +2,117 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { OpenClawRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
const {
|
||||
mockResolveGatewayConfig,
|
||||
mockCreateGatewaySession,
|
||||
mockPromptGateway,
|
||||
mockDescribeGatewayModel,
|
||||
mockResolveCliConfig,
|
||||
mockCreateCliSession,
|
||||
mockPromptCli,
|
||||
mockDescribeCliModel,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveGatewayConfig: vi.fn(),
|
||||
mockCreateGatewaySession: vi.fn(),
|
||||
mockPromptGateway: vi.fn(),
|
||||
mockDescribeGatewayModel: vi.fn(),
|
||||
mockResolveCliConfig: vi.fn(),
|
||||
mockCreateCliSession: vi.fn(),
|
||||
mockPromptCli: vi.fn(),
|
||||
mockDescribeCliModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
resolveGatewayConfig: mockResolveGatewayConfig,
|
||||
createGatewaySession: mockCreateGatewaySession,
|
||||
promptGateway: mockPromptGateway,
|
||||
describeGatewayModel: mockDescribeGatewayModel,
|
||||
resolveCliConfig: mockResolveCliConfig,
|
||||
createCliSession: mockCreateCliSession,
|
||||
promptCli: mockPromptCli,
|
||||
describeCliModel: mockDescribeCliModel,
|
||||
}));
|
||||
|
||||
describe("OpenClawRuntimeAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveGatewayConfig.mockReturnValue({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "token",
|
||||
agentId: "main",
|
||||
});
|
||||
mockDescribeGatewayModel.mockReturnValue("openclaw/main");
|
||||
mockCreateGatewaySession.mockImplementation((options) => ({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
gatewayToken: options.gatewayToken,
|
||||
agentId: options.agentId,
|
||||
sessionId: "session-123",
|
||||
messages: [{ role: "developer", content: options.systemPrompt }],
|
||||
callbacks: options.callbacks,
|
||||
}));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveCliConfig.mockReturnValue({
|
||||
binaryPath: "openclaw",
|
||||
agentId: "main",
|
||||
model: undefined,
|
||||
thinking: "off",
|
||||
cliTimeoutSec: 0,
|
||||
cliTimeoutMs: 300_000,
|
||||
useGateway: false,
|
||||
});
|
||||
mockCreateCliSession.mockImplementation(({ systemPrompt, agentId, callbacks }) => ({
|
||||
sessionId: "session-uuid-1",
|
||||
agentId: agentId ?? "main",
|
||||
systemPrompt,
|
||||
messages: [{ role: "developer", content: systemPrompt }],
|
||||
lastModelDescription: `openclaw/${agentId ?? "main"}`,
|
||||
callbacks,
|
||||
}));
|
||||
mockDescribeCliModel.mockReturnValue("openclaw/main");
|
||||
});
|
||||
|
||||
it("has stable runtime identity", () => {
|
||||
describe("OpenClawRuntimeAdapter — identity", () => {
|
||||
it("has stable id/name", () => {
|
||||
const adapter = new OpenClawRuntimeAdapter();
|
||||
expect(adapter.id).toBe("openclaw");
|
||||
expect(adapter.name).toBe("OpenClaw Runtime");
|
||||
});
|
||||
});
|
||||
|
||||
it("createSession returns gateway session with initial developer message", async () => {
|
||||
const adapter = new OpenClawRuntimeAdapter({ gatewayUrl: "http://localhost:18789", agentId: "ops" });
|
||||
|
||||
describe("OpenClawRuntimeAdapter — createSession", () => {
|
||||
it("delegates to createCliSession with systemPrompt + agentId + callbacks", async () => {
|
||||
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const result = await adapter.createSession({
|
||||
cwd: "/project",
|
||||
cwd: "/repo",
|
||||
systemPrompt: "You are helpful",
|
||||
onText: vi.fn(),
|
||||
onThinking: vi.fn(),
|
||||
onToolStart: vi.fn(),
|
||||
onToolEnd: vi.fn(),
|
||||
onText,
|
||||
onThinking,
|
||||
});
|
||||
|
||||
expect(mockResolveGatewayConfig).toHaveBeenCalledWith({ gatewayUrl: "http://localhost:18789", agentId: "ops" });
|
||||
expect(mockCreateGatewaySession).toHaveBeenCalledWith(
|
||||
expect(mockCreateCliSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "token",
|
||||
agentId: "main",
|
||||
systemPrompt: "You are helpful",
|
||||
agentId: "main", // resolved from defaults in our mock
|
||||
}),
|
||||
);
|
||||
expect(result.session.messages).toEqual([{ role: "developer", content: "You are helpful" }]);
|
||||
expect(result.sessionFile).toBeUndefined();
|
||||
expect(result.session.sessionId).toBe("session-uuid-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("promptWithFallback appends user message and delegates assistant handling to gateway client", async () => {
|
||||
describe("OpenClawRuntimeAdapter — promptWithFallback", () => {
|
||||
it("calls promptCli with session + prompt + resolved config", async () => {
|
||||
const adapter = new OpenClawRuntimeAdapter();
|
||||
const session = {
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "token",
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messages: [{ role: "developer" as const, content: "System" }],
|
||||
};
|
||||
mockPromptGateway.mockImplementation(async (activeSession) => {
|
||||
activeSession.messages.push({ role: "assistant", content: "Gateway response" });
|
||||
return "Gateway response";
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
|
||||
await adapter.promptWithFallback(session, "Hello", { onText: vi.fn() });
|
||||
|
||||
expect(mockPromptGateway).toHaveBeenCalledWith(session, "Hello", { onText: expect.any(Function) });
|
||||
expect(session.messages).toEqual([
|
||||
{ role: "developer", content: "System" },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Gateway response" },
|
||||
]);
|
||||
await adapter.promptWithFallback(session, "hi");
|
||||
expect(mockPromptCli).toHaveBeenCalledTimes(1);
|
||||
const [callSession, callPrompt, callConfig] = mockPromptCli.mock.calls[0];
|
||||
expect(callSession).toBe(session);
|
||||
expect(callPrompt).toBe("hi");
|
||||
expect(callConfig).toMatchObject({ binaryPath: "openclaw", useGateway: false });
|
||||
});
|
||||
|
||||
it("describeModel returns openclaw/<agentId>", () => {
|
||||
it("forwards override callbacks via the options arg", async () => {
|
||||
const adapter = new OpenClawRuntimeAdapter();
|
||||
const session = {
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
agentId: "ops",
|
||||
sessionId: "session-123",
|
||||
messages: [],
|
||||
};
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
const onText = vi.fn();
|
||||
await adapter.promptWithFallback(session, "p", { onText });
|
||||
const overrideCallbacks = mockPromptCli.mock.calls[0][3];
|
||||
expect(overrideCallbacks?.onText).toBe(onText);
|
||||
});
|
||||
});
|
||||
|
||||
const result = adapter.describeModel(session as any);
|
||||
|
||||
expect(mockDescribeGatewayModel).toHaveBeenCalledWith(session);
|
||||
expect(result).toBe("openclaw/main");
|
||||
describe("OpenClawRuntimeAdapter — describeModel/dispose", () => {
|
||||
it("describeModel delegates to describeCliModel", async () => {
|
||||
const adapter = new OpenClawRuntimeAdapter();
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "sys",
|
||||
});
|
||||
expect(adapter.describeModel(session)).toBe("openclaw/main");
|
||||
expect(mockDescribeCliModel).toHaveBeenCalledWith(session);
|
||||
});
|
||||
|
||||
it("dispose is a no-op", async () => {
|
||||
const adapter = new OpenClawRuntimeAdapter();
|
||||
await expect(adapter.dispose({} as any)).resolves.toBeUndefined();
|
||||
await expect(adapter.dispose!({} as any)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* OpenClaw Runtime Plugin
|
||||
*
|
||||
* Provides an executable OpenClaw runtime adapter for Fusion's plugin runtime
|
||||
* discovery and session execution pipeline.
|
||||
* Drives the local `openclaw` CLI as a subprocess (via
|
||||
* `openclaw --no-color agent --local --json`). No daemon required.
|
||||
*/
|
||||
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
|
||||
import { probeGateway, resolveGatewayConfig } from "./pi-module.js";
|
||||
import { resolveCliConfig } from "./pi-module.js";
|
||||
import { probeOpenClawBinary } from "./probe.js";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginContext,
|
||||
@@ -16,48 +17,50 @@ import type {
|
||||
} from "@fusion/plugin-sdk";
|
||||
|
||||
const OPENCLAW_RUNTIME_ID = "openclaw";
|
||||
const OPENCLAW_RUNTIME_VERSION = "0.1.0";
|
||||
const OPENCLAW_RUNTIME_VERSION = "0.2.0";
|
||||
|
||||
const openclawRuntimeMetadata: PluginRuntimeManifestMetadata = {
|
||||
runtimeId: OPENCLAW_RUNTIME_ID,
|
||||
name: "OpenClaw Runtime",
|
||||
description: "OpenClaw-backed AI session using the local OpenClaw gateway",
|
||||
description: "Drives the local `openclaw` CLI (openclaw/openclaw)",
|
||||
version: OPENCLAW_RUNTIME_VERSION,
|
||||
};
|
||||
|
||||
const openclawRuntimeFactory: PluginRuntimeFactory = async (ctx?: PluginContext) => {
|
||||
const config = resolveGatewayConfig(ctx?.settings);
|
||||
return new OpenClawRuntimeAdapter(config);
|
||||
return new OpenClawRuntimeAdapter(ctx?.settings as Record<string, unknown> | undefined);
|
||||
};
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-openclaw-runtime",
|
||||
name: "OpenClaw Runtime Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Provides OpenClaw runtime for Fusion AI agents",
|
||||
version: OPENCLAW_RUNTIME_VERSION,
|
||||
description:
|
||||
"Drives the local `openclaw` CLI for Fusion agents — embedded `--local` mode by default; gateway optional.",
|
||||
author: "Fusion Team",
|
||||
homepage: "https://github.com/gsxdsm/fusion",
|
||||
homepage: "https://docs.openclaw.ai/",
|
||||
runtime: openclawRuntimeMetadata,
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {
|
||||
onLoad: async (ctx) => {
|
||||
const config = resolveGatewayConfig(ctx.settings);
|
||||
const gatewayReachable = await probeGateway(config.gatewayUrl);
|
||||
const config = resolveCliConfig(ctx.settings);
|
||||
const probe = await probeOpenClawBinary({ binaryPath: config.binaryPath });
|
||||
|
||||
ctx.logger.info(
|
||||
`OpenClaw Runtime Plugin loaded (gateway: ${config.gatewayUrl}, reachable: ${gatewayReachable ? "yes" : "no"})`,
|
||||
probe.available
|
||||
? `OpenClaw Runtime Plugin loaded — binary=${config.binaryPath}${probe.version ? ` (${probe.version})` : ""}`
|
||||
: `OpenClaw Runtime Plugin loaded but binary not detected: ${probe.reason ?? "unknown"}`,
|
||||
);
|
||||
ctx.emitEvent("openclaw-runtime:loaded", {
|
||||
runtimeId: OPENCLAW_RUNTIME_ID,
|
||||
version: OPENCLAW_RUNTIME_VERSION,
|
||||
gatewayUrl: config.gatewayUrl,
|
||||
gatewayReachable,
|
||||
binaryAvailable: probe.available,
|
||||
binaryPath: probe.binaryPath ?? config.binaryPath,
|
||||
});
|
||||
},
|
||||
onUnload: () => {
|
||||
// No context available during unload
|
||||
// No persistent state to clean up — each prompt spawns a fresh subprocess.
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
@@ -68,4 +71,20 @@ const plugin: FusionPlugin = definePlugin({
|
||||
|
||||
export default plugin;
|
||||
|
||||
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
|
||||
// ── Public exports ────────────────────────────────────────────────────────────
|
||||
|
||||
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
|
||||
export { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
|
||||
export {
|
||||
resolveCliConfig,
|
||||
buildOpenClawArgs,
|
||||
createCliSession,
|
||||
promptCli,
|
||||
describeCliModel,
|
||||
extractStderrError,
|
||||
} from "./pi-module.js";
|
||||
export type { CliConfig, GatewaySession, OpenClawAgentJson } from "./types.js";
|
||||
|
||||
// Probe re-export for the dashboard's runtime-provider-probes façade.
|
||||
export { probeOpenClawBinary } from "./probe.js";
|
||||
export type { OpenClawBinaryStatus } from "./probe.js";
|
||||
|
||||
@@ -1,211 +1,328 @@
|
||||
/**
|
||||
* OpenClaw CLI spawn module.
|
||||
*
|
||||
* Drives the local `openclaw` binary via `openclaw --no-color agent --local --json`
|
||||
* and parses the resulting JSON document on stdout. No daemon required.
|
||||
*
|
||||
* (Filename kept as `pi-module.ts` for compatibility with imports — the tests,
|
||||
* runtime-adapter, and dashboard probe façade all import it under this name.)
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { GatewayCallbacks, GatewayConfig, GatewaySession } from "./types.js";
|
||||
import type {
|
||||
CliConfig,
|
||||
GatewayCallbacks,
|
||||
GatewaySession,
|
||||
OpenClawAgentJson,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "http://127.0.0.1:18789";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_BINARY = "openclaw";
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
const DEFAULT_THINKING = "off";
|
||||
const DEFAULT_TIMEOUT_SEC = 0; // 0 = no openclaw-side timeout
|
||||
const DEFAULT_CLI_TIMEOUT_MS = 300_000; // 5 min hard kill on our side
|
||||
|
||||
interface ToolCallDelta {
|
||||
index: number;
|
||||
id?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
// eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition
|
||||
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | undefined {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
const s = asString(v);
|
||||
if (s === undefined) return undefined;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function asBool(v: unknown): boolean | undefined {
|
||||
if (typeof v === "boolean") return v;
|
||||
const s = asString(v);
|
||||
if (s === undefined) return undefined;
|
||||
return s === "1" || s.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_RE, "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveCliConfig — settings + env-var resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveCliConfig(settings?: Record<string, unknown>): CliConfig {
|
||||
return {
|
||||
binaryPath:
|
||||
asString(settings?.binaryPath) ??
|
||||
asString(process.env.OPENCLAW_BIN) ??
|
||||
DEFAULT_BINARY,
|
||||
agentId:
|
||||
asString(settings?.agentId) ??
|
||||
asString(process.env.OPENCLAW_AGENT_ID) ??
|
||||
DEFAULT_AGENT_ID,
|
||||
model:
|
||||
asString(settings?.model) ??
|
||||
asString(process.env.OPENCLAW_MODEL),
|
||||
thinking:
|
||||
asString(settings?.thinking) ??
|
||||
asString(process.env.OPENCLAW_THINKING) ??
|
||||
DEFAULT_THINKING,
|
||||
cliTimeoutSec:
|
||||
asNumber(settings?.cliTimeoutSec) ??
|
||||
asNumber(process.env.OPENCLAW_TIMEOUT_SEC) ??
|
||||
DEFAULT_TIMEOUT_SEC,
|
||||
cliTimeoutMs:
|
||||
asNumber(settings?.cliTimeoutMs) ??
|
||||
asNumber(process.env.OPENCLAW_CLI_TIMEOUT_MS) ??
|
||||
DEFAULT_CLI_TIMEOUT_MS,
|
||||
useGateway:
|
||||
asBool(settings?.useGateway) ??
|
||||
asBool(process.env.OPENCLAW_USE_GATEWAY) ??
|
||||
false,
|
||||
};
|
||||
}
|
||||
|
||||
interface SseDeltaChunk {
|
||||
choices?: Array<{
|
||||
delta?: {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: ToolCallDelta[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildOpenClawArgs — argv builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveGatewayConfig(settings?: Record<string, unknown>): GatewayConfig {
|
||||
const gatewayUrlSetting = typeof settings?.gatewayUrl === "string" ? settings.gatewayUrl : undefined;
|
||||
const gatewayTokenSetting = typeof settings?.gatewayToken === "string" ? settings.gatewayToken : undefined;
|
||||
const agentIdSetting = typeof settings?.agentId === "string" ? settings.agentId : undefined;
|
||||
/**
|
||||
* Build the argv for a single openclaw agent invocation.
|
||||
*
|
||||
* `--no-color` is a TOP-LEVEL flag and must come BEFORE `agent`.
|
||||
* `--local` is appended unless `useGateway` is true.
|
||||
*/
|
||||
export function buildOpenClawArgs(
|
||||
config: CliConfig,
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): string[] {
|
||||
const args: string[] = ["--no-color", "agent"];
|
||||
|
||||
const gatewayUrl =
|
||||
gatewayUrlSetting?.trim() || process.env.OPENCLAW_GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL;
|
||||
const gatewayToken = gatewayTokenSetting?.trim() || process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
|
||||
const agentId = agentIdSetting?.trim() || process.env.OPENCLAW_AGENT_ID?.trim() || DEFAULT_AGENT_ID;
|
||||
if (!config.useGateway) args.push("--local");
|
||||
|
||||
return { gatewayUrl, gatewayToken, agentId };
|
||||
}
|
||||
args.push("--json");
|
||||
args.push("--session-id", sessionId);
|
||||
args.push("--message", message);
|
||||
args.push("--agent", config.agentId);
|
||||
|
||||
export async function probeGateway(gatewayUrl: string): Promise<boolean> {
|
||||
try {
|
||||
await fetch(gatewayUrl, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
if (config.model) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
|
||||
args.push("--thinking", config.thinking);
|
||||
args.push("--timeout", String(config.cliTimeoutSec));
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function createGatewaySession(options: {
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractStderrError — last meaningful line, ANSI-stripped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractStderrError(stderr: string, stdout?: string): string {
|
||||
const tryExtract = (raw: string): string | undefined => {
|
||||
if (!raw) return undefined;
|
||||
const lines = stripAnsi(raw)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
return lines.length > 0 ? lines[lines.length - 1] : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
tryExtract(stderr) ??
|
||||
tryExtract(stdout ?? "") ??
|
||||
"openclaw exited with non-zero status (no stderr)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCliSession — mints a UUID + transcript bookkeeping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createCliSession(opts: {
|
||||
systemPrompt: string;
|
||||
gatewayUrl: string;
|
||||
gatewayToken?: string;
|
||||
agentId: string;
|
||||
agentId?: string;
|
||||
callbacks?: GatewayCallbacks;
|
||||
}): GatewaySession {
|
||||
return {
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
gatewayToken: options.gatewayToken,
|
||||
agentId: options.agentId,
|
||||
sessionId: randomUUID(),
|
||||
messages: [{ role: "developer", content: options.systemPrompt }],
|
||||
callbacks: options.callbacks,
|
||||
dispose: () => undefined,
|
||||
agentId: opts.agentId ?? DEFAULT_AGENT_ID,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
messages: [{ role: "developer", content: opts.systemPrompt }],
|
||||
lastModelDescription: `openclaw/${opts.agentId ?? DEFAULT_AGENT_ID}`,
|
||||
lastUsage: undefined,
|
||||
callbacks: opts.callbacks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptGateway(
|
||||
// ---------------------------------------------------------------------------
|
||||
// promptCli — spawns openclaw, parses the JSON, fires callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function promptCli(
|
||||
session: GatewaySession,
|
||||
_prompt: string,
|
||||
options?: GatewayCallbacks,
|
||||
): Promise<string> {
|
||||
const callbacks = options ?? session.callbacks ?? {};
|
||||
message: string,
|
||||
config: CliConfig,
|
||||
callbacks?: GatewayCallbacks,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const args = buildOpenClawArgs(config, session.sessionId, message);
|
||||
const cb: GatewayCallbacks = { ...session.callbacks, ...callbacks };
|
||||
|
||||
const response = await fetch(new URL("/v1/chat/completions", session.gatewayUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(session.gatewayToken ? { authorization: `Bearer ${session.gatewayToken}` } : {}),
|
||||
"x-openclaw-agent-id": session.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: `openclaw:${session.agentId}`,
|
||||
messages: session.messages,
|
||||
stream: true,
|
||||
user: session.sessionId,
|
||||
}),
|
||||
});
|
||||
cb.onToolStart?.("openclaw.agent", { sessionId: session.sessionId });
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`OpenClaw gateway request failed (${response.status} ${response.statusText})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("OpenClaw gateway returned an empty response body");
|
||||
}
|
||||
const child = spawn(config.binaryPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const hardKill = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`openclaw: process timed out after ${config.cliTimeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, config.cliTimeoutMs);
|
||||
|
||||
let buffer = "";
|
||||
let assistantResponse = "";
|
||||
|
||||
const toolArgBuffers = new Map<number, string>();
|
||||
const toolNames = new Map<number, string>();
|
||||
const toolStarted = new Set<number>();
|
||||
const parsedToolArgs = new Map<number, unknown>();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
const onAbort = (): void => {
|
||||
if (settled) return;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
let boundaryIndex = buffer.indexOf("\n\n");
|
||||
while (boundaryIndex !== -1) {
|
||||
const eventChunk = buffer.slice(0, boundaryIndex);
|
||||
buffer = buffer.slice(boundaryIndex + 2);
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
const lines = eventChunk
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("data:"));
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardKill);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
const isNotFound = err.code === "ENOENT";
|
||||
reject(
|
||||
new Error(
|
||||
isNotFound
|
||||
? `openclaw: binary not found at "${config.binaryPath}". Install OpenClaw (npm i -g openclaw) or set binaryPath/OPENCLAW_BIN.`
|
||||
: `openclaw: spawn error — ${err.message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
for (const line of lines) {
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") {
|
||||
continue;
|
||||
}
|
||||
child.on("close", (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardKill);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
let parsed: SseDeltaChunk;
|
||||
try {
|
||||
parsed = JSON.parse(payload) as SseDeltaChunk;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`OpenClaw gateway returned invalid SSE JSON: ${message}`);
|
||||
}
|
||||
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (!delta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof delta.content === "string" && delta.content.length > 0) {
|
||||
assistantResponse += delta.content;
|
||||
callbacks.onText?.(delta.content);
|
||||
}
|
||||
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
||||
callbacks.onThinking?.(delta.reasoning_content);
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
const index = toolCall.index;
|
||||
const toolName = toolCall.function?.name;
|
||||
if (typeof toolName === "string" && toolName.length > 0) {
|
||||
toolNames.set(index, toolName);
|
||||
if (!toolStarted.has(index)) {
|
||||
callbacks.onToolStart?.(toolName);
|
||||
toolStarted.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
const nextChunk = toolCall.function?.arguments ?? "";
|
||||
const previous = toolArgBuffers.get(index) ?? "";
|
||||
const combined = previous + nextChunk;
|
||||
toolArgBuffers.set(index, combined);
|
||||
|
||||
try {
|
||||
const parsedArgs = combined ? (JSON.parse(combined) as unknown) : {};
|
||||
parsedToolArgs.set(index, parsedArgs);
|
||||
} catch {
|
||||
// Partial JSON; wait for more chunks.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (code !== 0) {
|
||||
const err =
|
||||
stderr.trim() || stdout.trim()
|
||||
? extractStderrError(stderr, stdout)
|
||||
: `openclaw: exited with code ${String(code)}`;
|
||||
reject(new Error(err));
|
||||
return;
|
||||
}
|
||||
|
||||
boundaryIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
// Exit 0 — parse JSON
|
||||
let parsed: OpenClawAgentJson;
|
||||
try {
|
||||
parsed = JSON.parse(stdout) as OpenClawAgentJson;
|
||||
} catch {
|
||||
reject(new Error(`openclaw: failed to parse JSON output (stdout=${stdout.slice(0, 200)})`));
|
||||
return;
|
||||
}
|
||||
|
||||
const remainder = decoder.decode();
|
||||
if (remainder) {
|
||||
buffer += remainder;
|
||||
}
|
||||
const payloads = parsed.payloads ?? [];
|
||||
const visibleText = payloads
|
||||
.filter((p) => !p.isError && !p.isReasoning)
|
||||
.map((p) => p.text ?? "")
|
||||
.filter((t) => t.length > 0)
|
||||
.join("");
|
||||
const reasoningText = payloads
|
||||
.filter((p) => p.isReasoning)
|
||||
.map((p) => p.text ?? "")
|
||||
.filter((t) => t.length > 0)
|
||||
.join("\n");
|
||||
const errorText = payloads
|
||||
.filter((p) => p.isError)
|
||||
.map((p) => p.text ?? "")
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
for (const [index, parsedArgs] of parsedToolArgs.entries()) {
|
||||
const resolvedName = toolNames.get(index) ?? "unknown_tool";
|
||||
if (!toolStarted.has(index)) {
|
||||
callbacks.onToolStart?.(resolvedName);
|
||||
toolStarted.add(index);
|
||||
}
|
||||
callbacks.onToolEnd?.(resolvedName, false, parsedArgs);
|
||||
}
|
||||
const finalText = visibleText || parsed.meta?.finalAssistantVisibleText || "";
|
||||
|
||||
session.messages.push({ role: "assistant", content: assistantResponse });
|
||||
return assistantResponse;
|
||||
if (finalText) cb.onText?.(finalText);
|
||||
if (reasoningText) cb.onThinking?.(reasoningText);
|
||||
|
||||
// Update transcript + session metadata
|
||||
session.messages.push({ role: "user", content: message });
|
||||
if (finalText) {
|
||||
session.messages.push({ role: "assistant", content: finalText });
|
||||
}
|
||||
const agentMeta = parsed.meta?.agentMeta;
|
||||
if (agentMeta?.usage) session.lastUsage = agentMeta.usage;
|
||||
if (agentMeta?.provider && agentMeta.model) {
|
||||
session.lastModelDescription = `openclaw/${session.agentId}/${agentMeta.provider}/${agentMeta.model}`;
|
||||
}
|
||||
|
||||
const metaError = parsed.meta?.error;
|
||||
const isError = !!metaError;
|
||||
|
||||
cb.onToolEnd?.(
|
||||
"openclaw.agent",
|
||||
isError,
|
||||
{
|
||||
usage: agentMeta?.usage,
|
||||
provider: agentMeta?.provider,
|
||||
model: agentMeta?.model,
|
||||
...(metaError ? { error: metaError } : {}),
|
||||
...(errorText.length > 0 ? { toolErrors: errorText } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGatewayModel(session: GatewaySession): string {
|
||||
return `openclaw/${session.agentId}`;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// describeCliModel — for runtime-adapter's describeModel()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function describeCliModel(session: GatewaySession): string {
|
||||
return session.lastModelDescription || `openclaw/${session.agentId}`;
|
||||
}
|
||||
|
||||
154
plugins/fusion-plugin-openclaw-runtime/src/probe.ts
Normal file
154
plugins/fusion-plugin-openclaw-runtime/src/probe.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Public helper for probing the locally-installed OpenClaw CLI binary.
|
||||
*
|
||||
* Used by dashboards, health checks, and onboarding flows to determine
|
||||
* whether `openclaw` is available before attempting any agent session.
|
||||
*
|
||||
* Design choices mirror the Claude CLI probe in packages/dashboard:
|
||||
* - No caching. PATH can change between requests.
|
||||
* - Short timeout. A misbehaving shim must not block callers.
|
||||
* - Never throws. Any failure is captured in the returned status object.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/** Default timeout for the version probe. */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Structured result from probing the OpenClaw CLI binary.
|
||||
*/
|
||||
export interface OpenClawBinaryStatus {
|
||||
/** `true` if the binary was found on PATH and completed successfully. */
|
||||
available: boolean;
|
||||
/** Resolved binary path (from `which`/`where`), if determinable. */
|
||||
binaryPath?: string;
|
||||
/** Trimmed version string from `openclaw --version`, e.g. `"OpenClaw 2026.4.26 (be8c246)"`. */
|
||||
version?: string;
|
||||
/** Human-readable failure reason when `available === false`. */
|
||||
reason?: string;
|
||||
/** Wall-clock milliseconds consumed by the probe. */
|
||||
probeDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `openclaw --version` and return a structured availability status.
|
||||
*
|
||||
* Never throws — failures are represented as `available: false` with a
|
||||
* descriptive `reason` so HTTP handlers can render provider cards safely.
|
||||
*
|
||||
* @param opts.binaryPath - Override the binary path. Defaults to `"openclaw"`.
|
||||
* @param opts.timeoutMs - Max milliseconds to wait. Defaults to 2,000.
|
||||
*/
|
||||
export async function probeOpenClawBinary(
|
||||
opts: { binaryPath?: string; timeoutMs?: number } = {},
|
||||
): Promise<OpenClawBinaryStatus> {
|
||||
const startedAt = Date.now();
|
||||
const binary = opts.binaryPath ?? "openclaw";
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
|
||||
const resolvedPath = await tryResolveBinaryPath(binary);
|
||||
|
||||
return new Promise<OpenClawBinaryStatus>((resolvePromise) => {
|
||||
const finish = (
|
||||
partial: Omit<OpenClawBinaryStatus, "probeDurationMs">,
|
||||
): void => {
|
||||
resolvePromise({ ...partial, probeDurationMs: Date.now() - startedAt });
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
|
||||
const child = spawn(resolvedPath ?? binary, ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Process already gone.
|
||||
}
|
||||
finish({
|
||||
available: false,
|
||||
binaryPath: resolvedPath,
|
||||
reason: `Probe timed out after ${timeoutMs}ms`,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const isNotFound = err.code === "ENOENT";
|
||||
finish({
|
||||
available: false,
|
||||
binaryPath: resolvedPath,
|
||||
reason: isNotFound
|
||||
? `\`${binary}\` not found on PATH — install via: npm install -g openclaw`
|
||||
: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
finish({
|
||||
available: true,
|
||||
binaryPath: resolvedPath,
|
||||
version: stdout.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
finish({
|
||||
available: false,
|
||||
binaryPath: resolvedPath,
|
||||
reason:
|
||||
stderr.trim() ||
|
||||
`openclaw --version exited with code ${String(code)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to resolve the absolute path to a binary via `which` (POSIX) or
|
||||
* `where` (Windows). Returns `undefined` on failure — the probe above is the
|
||||
* real authority.
|
||||
*/
|
||||
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
|
||||
return new Promise<string | undefined>((resolvePromise) => {
|
||||
const which = process.platform === "win32" ? "where" : "which";
|
||||
const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let out = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
out += chunk.toString("utf-8");
|
||||
});
|
||||
child.on("error", () => resolvePromise(undefined));
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
const first = out.trim().split(/\r?\n/)[0];
|
||||
resolvePromise(first?.length ? first : undefined);
|
||||
} else {
|
||||
resolvePromise(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,45 @@
|
||||
/**
|
||||
* OpenClaw Runtime Adapter — drives the local `openclaw` CLI as a subprocess.
|
||||
*
|
||||
* Each call to `promptWithFallback` invokes
|
||||
* `openclaw --no-color agent --local --json --session-id <uuid> --message <prompt>`
|
||||
* (and `--model`, `--thinking`, `--timeout`, `--agent` if configured),
|
||||
* parses the JSON document on stdout, and forwards visible/reasoning text
|
||||
* via the session callbacks.
|
||||
*
|
||||
* Session continuity: the UUID minted on session create is reused on every
|
||||
* subsequent prompt as `--session-id`, so openclaw resumes the same agent
|
||||
* conversation server-side.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeOptions,
|
||||
AgentSessionResult,
|
||||
GatewayConfig,
|
||||
CliConfig,
|
||||
GatewaySession,
|
||||
} from "./types.js";
|
||||
import {
|
||||
createGatewaySession,
|
||||
describeGatewayModel,
|
||||
promptGateway,
|
||||
resolveGatewayConfig,
|
||||
createCliSession,
|
||||
describeCliModel,
|
||||
promptCli,
|
||||
resolveCliConfig,
|
||||
} from "./pi-module.js";
|
||||
|
||||
export class OpenClawRuntimeAdapter implements AgentRuntime {
|
||||
readonly id = "openclaw";
|
||||
readonly name = "OpenClaw Runtime";
|
||||
|
||||
private readonly config: GatewayConfig;
|
||||
private readonly config: CliConfig;
|
||||
|
||||
constructor(settings?: Partial<GatewayConfig>) {
|
||||
this.config = resolveGatewayConfig(settings as Record<string, unknown> | undefined);
|
||||
constructor(settings?: Partial<CliConfig> | Record<string, unknown>) {
|
||||
this.config = resolveCliConfig(settings as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: this.config.gatewayUrl,
|
||||
gatewayToken: this.config.gatewayToken,
|
||||
agentId: this.config.agentId,
|
||||
const session = createCliSession({
|
||||
systemPrompt: options.systemPrompt,
|
||||
agentId: this.config.agentId,
|
||||
callbacks: {
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
@@ -36,23 +48,25 @@ export class OpenClawRuntimeAdapter implements AgentRuntime {
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionFile: undefined,
|
||||
};
|
||||
return { session, sessionFile: undefined };
|
||||
}
|
||||
|
||||
async promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise<void> {
|
||||
session.messages.push({ role: "user", content: prompt });
|
||||
|
||||
await promptGateway(session, prompt, options as Parameters<typeof promptGateway>[2]);
|
||||
async promptWithFallback(
|
||||
session: GatewaySession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
): Promise<void> {
|
||||
const overrideCallbacks = (options ?? undefined) as
|
||||
| Parameters<typeof promptCli>[3]
|
||||
| undefined;
|
||||
await promptCli(session, prompt, this.config, overrideCallbacks);
|
||||
}
|
||||
|
||||
describeModel(session: GatewaySession): string {
|
||||
return describeGatewayModel(session);
|
||||
return describeCliModel(session);
|
||||
}
|
||||
|
||||
async dispose(_session: GatewaySession): Promise<void> {
|
||||
// OpenClaw gateway sessions are managed remotely; no local cleanup required.
|
||||
// No persistent resources — each prompt spawns a fresh subprocess.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* OpenClaw runtime adapter contracts.
|
||||
*
|
||||
* These mirror the engine runtime interface while keeping this plugin package
|
||||
* decoupled from internal engine modules.
|
||||
* The runtime drives the local `openclaw` CLI as a subprocess (no daemon).
|
||||
* Types are kept local to this package to stay decoupled from internal engine modules.
|
||||
*/
|
||||
|
||||
export type GatewayRole = "developer" | "user" | "assistant";
|
||||
@@ -12,10 +12,21 @@ export interface GatewayMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface GatewayConfig {
|
||||
gatewayUrl: string;
|
||||
gatewayToken?: string;
|
||||
/**
|
||||
* Resolved settings used by the CLI spawn helpers.
|
||||
*
|
||||
* `useGateway: false` (default) passes `--local` to the openclaw CLI so the
|
||||
* agent runs embedded with no daemon. `useGateway: true` lets the CLI try the
|
||||
* WebSocket gateway and fall back to embedded if unreachable (slower).
|
||||
*/
|
||||
export interface CliConfig {
|
||||
binaryPath: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
thinking: string; // off | minimal | low | medium | high | xhigh | adaptive | max
|
||||
cliTimeoutSec: number; // openclaw-side timeout (0 = no limit)
|
||||
cliTimeoutMs: number; // hard kill on our side
|
||||
useGateway: boolean;
|
||||
}
|
||||
|
||||
export interface GatewayCallbacks {
|
||||
@@ -25,9 +36,23 @@ export interface GatewayCallbacks {
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface GatewaySession extends GatewayConfig {
|
||||
/**
|
||||
* In-memory session object held by the adapter. The `sessionId` is passed to
|
||||
* `openclaw agent --session-id` so consecutive calls share state server-side.
|
||||
*
|
||||
* `messages` is a transcript-style local mirror; the openclaw CLI does NOT
|
||||
* receive this array — it manages its own session store under
|
||||
* `~/.openclaw/agents/<agentId>/sessions/`.
|
||||
*/
|
||||
export interface GatewaySession {
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
systemPrompt: string;
|
||||
messages: GatewayMessage[];
|
||||
/** Last-known model description; populated after each turn from the CLI JSON. */
|
||||
lastModelDescription: string;
|
||||
/** Last-known token usage from the CLI JSON's `meta.agentMeta.usage`. */
|
||||
lastUsage?: Record<string, number>;
|
||||
callbacks?: GatewayCallbacks;
|
||||
dispose?: () => Promise<void> | void;
|
||||
}
|
||||
@@ -60,3 +85,30 @@ export interface AgentRuntime {
|
||||
describeModel(session: GatewaySession): string;
|
||||
dispose?(session: GatewaySession): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the JSON document emitted by `openclaw agent --json` on stdout.
|
||||
* Used by the CLI parser; declared here so callers can introspect the result.
|
||||
*/
|
||||
export interface OpenClawAgentJson {
|
||||
payloads?: Array<{
|
||||
text?: string;
|
||||
isError?: boolean;
|
||||
isReasoning?: boolean;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
}>;
|
||||
meta?: {
|
||||
durationMs?: number;
|
||||
aborted?: boolean;
|
||||
stopReason?: string;
|
||||
finalAssistantVisibleText?: string;
|
||||
error?: { kind: string; message: string };
|
||||
agentMeta?: {
|
||||
sessionId?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
usage?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user