fix(plugins): re-export probe symbols + declare plugin deps in dashboard

- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` /
  `probeOpenClawBinary` and their status types so the dashboard's
  `runtime-provider-probes.ts` façade can import them via the public
  package entry instead of deep paths.
- Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`,
  `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so
  pnpm symlinks them into `packages/dashboard/node_modules/`. Without
  these, the new probe imports failed with "Cannot find module" during
  `pnpm typecheck`.

This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are
in the in-flight Hermes plugin rewrite (runtime-adapter still imports
from a deleted `./pi-module.js`; the new `index.ts` calls a factory
with the wrong arg type) and should be resolved by the same change set
that landed the rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 20:22:18 -07:00
committed by gsxdsm
parent 1a1bec1fb4
commit d3e1c28cef
82 changed files with 8481 additions and 2833 deletions

View File

@@ -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");
}
});
});

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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("OpenClawRuntimeAdapterdescribeModel/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();
});
});

View File

@@ -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";

View File

@@ -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}`;
}

View 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);
}
});
});
}

View File

@@ -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.
}
}

View File

@@ -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>;
};
};
}