diff --git a/plugins/fusion-plugin-openclaw-runtime/README.md b/plugins/fusion-plugin-openclaw-runtime/README.md index f777f074f..2aaf4ca11 100644 --- a/plugins/fusion-plugin-openclaw-runtime/README.md +++ b/plugins/fusion-plugin-openclaw-runtime/README.md @@ -1,16 +1,19 @@ # OpenClaw Runtime Plugin -Provides an executable OpenClaw runtime plugin for Fusion. This package enables runtime registration, discovery, and session execution so agents configured with `runtimeConfig.runtimeHint: "openclaw"` can run through the standard runtime adapter contract. +`fusion-plugin-openclaw-runtime` provides the `openclaw` runtime hint for Fusion agents by calling a **locally running OpenClaw gateway** over HTTP. -## Overview +Unlike the default runtime, this plugin does **not** delegate to Fusion's internal pi runtime. It talks directly to OpenClaw's OpenAI-compatible endpoint. -This plugin follows the runtime adapter pattern used by other executable plugin runtimes: +## Prerequisites -- Registers OpenClaw runtime metadata for resolver discovery -- Creates executable runtime sessions via `createFnAgent` -- Delegates prompt execution through `promptWithFallback` -- Exposes model descriptions through `describeModel` -- Supports best-effort session disposal via `dispose()` +1. Install OpenClaw globally: + +```bash +npm i -g openclaw +``` + +2. Start your OpenClaw gateway with chat-completions endpoint enabled. +3. Ensure the gateway is reachable from Fusion (default: `http://127.0.0.1:18789`). ## Installation @@ -33,7 +36,40 @@ fn plugin install ./plugins/fusion-plugin-openclaw-runtime - **Runtime ID:** `openclaw` - **Runtime name:** `OpenClaw Runtime` - **Version:** `0.1.0` -- **Description:** OpenClaw-backed AI session using the user's configured pi provider and model + +## Plugin Settings + +Configure via plugin settings (`ctx.settings`) or environment variables. + +| Setting key | Env fallback | Default | +| --- | --- | --- | +| `gatewayUrl` | `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | +| `gatewayToken` | `OPENCLAW_GATEWAY_TOKEN` | _unset_ | +| `agentId` | `OPENCLAW_AGENT_ID` | `main` | + +Settings take precedence over environment variables. + +## How Execution Works + +For each prompt, the runtime sends a streaming request to: + +- `POST /v1/chat/completions` +- `Content-Type: application/json` +- `Authorization: Bearer ` (when configured) +- `x-openclaw-agent-id: ` + +Request payload includes: + +- `model: "openclaw:"` +- `stream: true` +- `messages: [...]` +- `user: ` (so repeated turns share a stable gateway session) + +Streaming uses SSE (`data: ...` + `[DONE]`), with callbacks wired for: + +- text deltas (`choices[0].delta.content`) +- reasoning deltas (`choices[0].delta.reasoning_content`) +- tool call lifecycle (`choices[0].delta.tool_calls`) ## Agent Configuration @@ -49,12 +85,14 @@ Configure an agent to target OpenClaw via `runtimeConfig.runtimeHint`: } ``` +## Notes + +- This plugin no longer depends on `@fusion/engine`. +- Session cleanup is a no-op client-side; OpenClaw manages gateway sessions. + ## Local Development ```bash -# Run plugin tests pnpm --filter @fusion-plugin-examples/openclaw-runtime test - -# Build plugin output to dist/ pnpm --filter @fusion-plugin-examples/openclaw-runtime build ``` diff --git a/plugins/fusion-plugin-openclaw-runtime/package.json b/plugins/fusion-plugin-openclaw-runtime/package.json index a9f5030b4..54c05f010 100644 --- a/plugins/fusion-plugin-openclaw-runtime/package.json +++ b/plugins/fusion-plugin-openclaw-runtime/package.json @@ -21,7 +21,6 @@ "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { - "@fusion/engine": "workspace:*", "@fusion/plugin-sdk": "workspace:*" }, "devDependencies": { diff --git a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/engine-guard-stub.ts b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/engine-guard-stub.ts deleted file mode 100644 index f095c47bb..000000000 --- a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/engine-guard-stub.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Resolution stub for @fusion/engine in plugin test mode. - * - * Vitest resolves mocked module IDs before applying vi.mock factories. The - * real @fusion/engine workspace package points to dist outputs that are not - * built for plugin-local test runs, so we alias to this stub in vitest config. - * - * Any direct import of @fusion/engine in plugin tests should still fail fast. - */ -const guardError = () => - new Error( - "Guard: @fusion/engine was imported without an explicit mock. Runtime plugin tests must mock '../pi-module.js' to prevent loading the real engine.", - ); - -export const createFnAgent = () => { - throw guardError(); -}; - -export const promptWithFallback = async () => { - throw guardError(); -}; - -export const describeModel = () => { - throw guardError(); -}; diff --git a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/engine-guard.test.ts b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/engine-guard.test.ts deleted file mode 100644 index 002606ec2..000000000 --- a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/engine-guard.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Verifies the engine guard mock is active. - * - * This test ensures that the setup-engine-guard.ts setup file is correctly - * loaded and that any test importing @fusion/engine without mocking pi-module - * would fail fast with a descriptive error. - */ -import { describe, it, expect } from "vitest"; - -describe("engine import guard", () => { - it("should have @fusion/engine mock installed (prevents real engine load)", () => { - // The guard is verified indirectly: if this test file runs at all, - // the setup-engine-guard.ts loaded successfully. The guard throws only - // when a test file actually imports @fusion/engine without mocking - // pi-module.js — and since we don't do that here, we confirm the setup - // is wired without triggering the error. - expect(true).toBe(true); - }); - - it("should mock pi-module seam (not load real engine)", async () => { - // Dynamically import pi-module to verify it is mocked, not the real one. - // Since this test file has no vi.mock("../pi-module.js"), it relies on - // no code path reaching pi-module at all. The guard in setup-engine-guard.ts - // would throw if the real @fusion/engine were loaded. - // - // We do NOT import pi-module here because that would trigger the guard. - // Instead we verify the setup file exists and is wired via vitest config. - const { existsSync } = await import("node:fs"); - const { join } = await import("node:path"); - const guardPath = join(import.meta.dirname, "setup-engine-guard.ts"); - expect(existsSync(guardPath)).toBe(true); - }); -}); diff --git a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/gateway-client.test.ts b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/gateway-client.test.ts new file mode 100644 index 000000000..8003d2ca4 --- /dev/null +++ b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/gateway-client.test.ts @@ -0,0 +1,208 @@ +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({ + 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"); + }); +}); diff --git a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/index.test.ts index 5db14f76b..e580cc1ff 100644 --- a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/index.test.ts @@ -1,15 +1,29 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({ - mockCreateFnAgent: vi.fn(), - mockPromptWithFallback: vi.fn(), - mockDescribeModel: vi.fn().mockReturnValue("unknown model"), +const { + mockResolveGatewayConfig, + mockCreateGatewaySession, + mockPromptGateway, + mockDescribeGatewayModel, + mockProbeGateway, +} = vi.hoisted(() => ({ + mockResolveGatewayConfig: vi.fn().mockReturnValue({ + gatewayUrl: "http://127.0.0.1:18789", + gatewayToken: undefined, + agentId: "main", + }), + mockCreateGatewaySession: vi.fn(), + mockPromptGateway: vi.fn(), + mockDescribeGatewayModel: vi.fn().mockReturnValue("openclaw/main"), + mockProbeGateway: vi.fn().mockResolvedValue(true), })); vi.mock("../pi-module.js", () => ({ - createFnAgent: mockCreateFnAgent, - promptWithFallback: mockPromptWithFallback, - describeModel: mockDescribeModel, + resolveGatewayConfig: mockResolveGatewayConfig, + createGatewaySession: mockCreateGatewaySession, + promptGateway: mockPromptGateway, + describeGatewayModel: mockDescribeGatewayModel, + probeGateway: mockProbeGateway, })); import plugin, { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID } from "../index.js"; @@ -53,6 +67,7 @@ function createMockContext(overrides: Partial = {}): MockContext { describe("openclaw-runtime plugin", () => { beforeEach(() => { vi.clearAllMocks(); + mockProbeGateway.mockResolvedValue(true); }); afterEach(() => { @@ -86,14 +101,26 @@ describe("openclaw-runtime plugin", () => { }); describe("hooks", () => { - it("onLoad should log startup message and emit loaded event", async () => { + 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(ctx.logger.info).toHaveBeenCalledWith("OpenClaw Runtime Plugin loaded"); + 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", { runtimeId: OPENCLAW_RUNTIME_ID, version: "0.1.0", + gatewayUrl: "http://localhost:18789", + gatewayReachable: true, }); }); @@ -110,8 +137,21 @@ describe("openclaw-runtime plugin", () => { }); it("runtime factory should return executable runtime adapter", async () => { - const runtime = (await openclawRuntimeFactory(createMockContext() as any)) as OpenClawRuntimeAdapter; + const runtime = (await openclawRuntimeFactory( + createMockContext({ + settings: { + gatewayUrl: "http://settings-gateway:18789", + gatewayToken: "plugin-token", + agentId: "ops", + }, + }) as any, + )) as OpenClawRuntimeAdapter; + 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"); diff --git a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/runtime-adapter.test.ts index 95623a0cc..229a239fa 100644 --- a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/runtime-adapter.test.ts @@ -1,97 +1,116 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { OpenClawRuntimeAdapter } from "../runtime-adapter.js"; -const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({ - mockCreateFnAgent: vi.fn(), - mockPromptWithFallback: vi.fn(), - mockDescribeModel: vi.fn(), +const { + mockResolveGatewayConfig, + mockCreateGatewaySession, + mockPromptGateway, + mockDescribeGatewayModel, +} = vi.hoisted(() => ({ + mockResolveGatewayConfig: vi.fn(), + mockCreateGatewaySession: vi.fn(), + mockPromptGateway: vi.fn(), + mockDescribeGatewayModel: vi.fn(), })); vi.mock("../pi-module.js", () => ({ - createFnAgent: mockCreateFnAgent, - promptWithFallback: mockPromptWithFallback, - describeModel: mockDescribeModel, + resolveGatewayConfig: mockResolveGatewayConfig, + createGatewaySession: mockCreateGatewaySession, + promptGateway: mockPromptGateway, + describeGatewayModel: mockDescribeGatewayModel, })); describe("OpenClawRuntimeAdapter", () => { - let adapter: OpenClawRuntimeAdapter; - beforeEach(() => { vi.clearAllMocks(); - mockDescribeModel.mockReturnValue("mock/anthropic-claude"); - adapter = new OpenClawRuntimeAdapter(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + 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, + })); }); it("has stable runtime identity", () => { + const adapter = new OpenClawRuntimeAdapter(); expect(adapter.id).toBe("openclaw"); expect(adapter.name).toBe("OpenClaw Runtime"); }); - it("delegates createSession to createFnAgent with mapped options", async () => { - const mockSession = { dispose: vi.fn() }; - mockCreateFnAgent.mockResolvedValue({ session: mockSession, sessionFile: "/tmp/session.json" }); + it("createSession returns gateway session with initial developer message", async () => { + const adapter = new OpenClawRuntimeAdapter({ gatewayUrl: "http://localhost:18789", agentId: "ops" }); const result = await adapter.createSession({ cwd: "/project", systemPrompt: "You are helpful", - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - fallbackProvider: "openai", - fallbackModelId: "gpt-4o", - skills: ["bash"], + onText: vi.fn(), + onThinking: vi.fn(), + onToolStart: vi.fn(), + onToolEnd: vi.fn(), }); - expect(mockCreateFnAgent).toHaveBeenCalledWith({ - cwd: "/project", - systemPrompt: "You are helpful", - tools: undefined, - customTools: undefined, - onText: undefined, - onThinking: undefined, - onToolStart: undefined, - onToolEnd: undefined, - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - fallbackProvider: "openai", - fallbackModelId: "gpt-4o", - defaultThinkingLevel: undefined, - sessionManager: undefined, - skillSelection: undefined, - skills: ["bash"], + expect(mockResolveGatewayConfig).toHaveBeenCalledWith({ gatewayUrl: "http://localhost:18789", agentId: "ops" }); + expect(mockCreateGatewaySession).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayUrl: "http://127.0.0.1:18789", + gatewayToken: "token", + agentId: "main", + systemPrompt: "You are helpful", + }), + ); + expect(result.session.messages).toEqual([{ role: "developer", content: "You are helpful" }]); + expect(result.sessionFile).toBeUndefined(); + }); + + it("promptWithFallback appends user message and delegates assistant handling to gateway client", 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"; }); - expect(result.session).toBe(mockSession); - expect(result.sessionFile).toBe("/tmp/session.json"); + + 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" }, + ]); }); - it("delegates promptWithFallback to pi seam", async () => { - const session = { id: "s-1" }; - mockPromptWithFallback.mockResolvedValue(undefined); - - await adapter.promptWithFallback(session as any, "Hello", { images: [] }); - - expect(mockPromptWithFallback).toHaveBeenCalledWith(session, "Hello", { images: [] }); - }); - - it("delegates describeModel to pi seam", () => { - const session = { id: "s-2" }; - mockDescribeModel.mockReturnValue("anthropic/claude-sonnet-4-5"); + it("describeModel returns openclaw/", () => { + const adapter = new OpenClawRuntimeAdapter(); + const session = { + gatewayUrl: "http://127.0.0.1:18789", + agentId: "ops", + sessionId: "session-123", + messages: [], + }; const result = adapter.describeModel(session as any); - expect(mockDescribeModel).toHaveBeenCalledWith(session); - expect(result).toBe("anthropic/claude-sonnet-4-5"); + expect(mockDescribeGatewayModel).toHaveBeenCalledWith(session); + expect(result).toBe("openclaw/main"); }); - it("dispose calls session.dispose when present and no-ops otherwise", async () => { - const disposeMock = vi.fn().mockResolvedValue(undefined); - - await adapter.dispose({ dispose: disposeMock }); - await expect(adapter.dispose({ id: "no-dispose" } as any)).resolves.toBeUndefined(); - - expect(disposeMock).toHaveBeenCalledTimes(1); + it("dispose is a no-op", async () => { + const adapter = new OpenClawRuntimeAdapter(); + await expect(adapter.dispose({} as any)).resolves.toBeUndefined(); }); }); diff --git a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/setup-engine-guard.ts b/plugins/fusion-plugin-openclaw-runtime/src/__tests__/setup-engine-guard.ts deleted file mode 100644 index 0cbd48392..000000000 --- a/plugins/fusion-plugin-openclaw-runtime/src/__tests__/setup-engine-guard.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Engine import guard for plugin tests. - * - * Runtime plugin tests mock the seam module (../pi-module.js) to avoid loading - * the real @fusion/engine, which pulls in @fusion/core and triggers homedir()- - * based path resolution. This setup file installs a global vi.mock on - * @fusion/engine that throws if the real module is ever loaded without an - * explicit override. - * - * If you see the guard error in a test: - * 1. Add `vi.mock("../pi-module.js", ...)` at the top of the failing test - * 2. If you genuinely need to import @fusion/engine, you must also add HOME - * isolation setup (see setup-test-isolation.ts in packages/core) to this - * plugin's vitest config setupFiles before the guard. - */ -import { vi } from "vitest"; - -vi.mock("@fusion/engine", () => { - throw new Error( - "Guard: @fusion/engine was imported without an explicit mock. " + - "Runtime plugin tests must mock '../pi-module.js' to prevent loading " + - "the real engine. If you need the real engine, add HOME isolation " + - "setup (setup-test-isolation.ts) to vitest config setupFiles BEFORE " + - "this guard, and remove or override this mock.", - ); -}); diff --git a/plugins/fusion-plugin-openclaw-runtime/src/index.ts b/plugins/fusion-plugin-openclaw-runtime/src/index.ts index 852c7c136..d5a88dc8d 100644 --- a/plugins/fusion-plugin-openclaw-runtime/src/index.ts +++ b/plugins/fusion-plugin-openclaw-runtime/src/index.ts @@ -7,8 +7,10 @@ import { definePlugin } from "@fusion/plugin-sdk"; import { OpenClawRuntimeAdapter } from "./runtime-adapter.js"; +import { probeGateway, resolveGatewayConfig } from "./pi-module.js"; import type { FusionPlugin, + PluginContext, PluginRuntimeFactory, PluginRuntimeManifestMetadata, } from "@fusion/plugin-sdk"; @@ -19,12 +21,13 @@ const OPENCLAW_RUNTIME_VERSION = "0.1.0"; const openclawRuntimeMetadata: PluginRuntimeManifestMetadata = { runtimeId: OPENCLAW_RUNTIME_ID, name: "OpenClaw Runtime", - description: "OpenClaw-backed AI session using the user's configured pi provider and model", + description: "OpenClaw-backed AI session using the local OpenClaw gateway", version: OPENCLAW_RUNTIME_VERSION, }; -const openclawRuntimeFactory: PluginRuntimeFactory = async () => { - return new OpenClawRuntimeAdapter(); +const openclawRuntimeFactory: PluginRuntimeFactory = async (ctx?: PluginContext) => { + const config = resolveGatewayConfig(ctx?.settings); + return new OpenClawRuntimeAdapter(config); }; const plugin: FusionPlugin = definePlugin({ @@ -39,11 +42,18 @@ const plugin: FusionPlugin = definePlugin({ }, state: "installed", hooks: { - onLoad: (ctx) => { - ctx.logger.info("OpenClaw Runtime Plugin loaded"); + onLoad: async (ctx) => { + const config = resolveGatewayConfig(ctx.settings); + const gatewayReachable = await probeGateway(config.gatewayUrl); + + ctx.logger.info( + `OpenClaw Runtime Plugin loaded (gateway: ${config.gatewayUrl}, reachable: ${gatewayReachable ? "yes" : "no"})`, + ); ctx.emitEvent("openclaw-runtime:loaded", { runtimeId: OPENCLAW_RUNTIME_ID, version: OPENCLAW_RUNTIME_VERSION, + gatewayUrl: config.gatewayUrl, + gatewayReachable, }); }, onUnload: () => { diff --git a/plugins/fusion-plugin-openclaw-runtime/src/pi-module.ts b/plugins/fusion-plugin-openclaw-runtime/src/pi-module.ts index 9ac3034b0..a2c5406e9 100644 --- a/plugins/fusion-plugin-openclaw-runtime/src/pi-module.ts +++ b/plugins/fusion-plugin-openclaw-runtime/src/pi-module.ts @@ -1,53 +1,211 @@ -/** - * Pi Module Seam - * - * Provides a mockable import path for pi functions used by the OpenClawRuntimeAdapter. - * Tests intercept this module via `vi.mock("../pi-module.js", ...)`. The runtime - * implementations come from @fusion/engine; the local types provide a loose - * surface so the adapter doesn't have to depend on @fusion/engine's full types. - */ -import { - createFnAgent as _createFnAgent, - promptWithFallback as _promptWithFallback, - describeModel as _describeModel, -} from "@fusion/engine"; +import { randomUUID } from "node:crypto"; +import type { GatewayCallbacks, GatewayConfig, GatewaySession } from "./types.js"; -export interface PiAgentSession { - dispose?: () => Promise | void; +const DEFAULT_GATEWAY_URL = "http://127.0.0.1:18789"; +const DEFAULT_AGENT_ID = "main"; + +interface ToolCallDelta { + index: number; + id?: string; + function?: { + name?: string; + arguments?: string; + }; } -export interface PiAgentResult { - session: PiAgentSession; - sessionFile?: string; +interface SseDeltaChunk { + choices?: Array<{ + delta?: { + content?: string; + reasoning_content?: string; + tool_calls?: ToolCallDelta[]; + }; + }>; } -export interface PiAgentOptions { - cwd: string; +export function resolveGatewayConfig(settings?: Record): 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; + + 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; + + return { gatewayUrl, gatewayToken, agentId }; +} + +export async function probeGateway(gatewayUrl: string): Promise { + try { + await fetch(gatewayUrl, { + method: "HEAD", + signal: AbortSignal.timeout(2_000), + }); + return true; + } catch { + return false; + } +} + +export function createGatewaySession(options: { systemPrompt: string; - tools?: unknown; - customTools?: unknown; - onText?: (text: string) => void; - onThinking?: (text: string) => void; - onToolStart?: (toolName: string, args?: unknown) => void; - onToolEnd?: (toolName: string, result?: unknown) => void; - defaultProvider?: string; - defaultModelId?: string; - fallbackProvider?: string; - fallbackModelId?: string; - defaultThinkingLevel?: string; - sessionManager?: unknown; - skillSelection?: unknown; - skills?: string[]; + gatewayUrl: string; + gatewayToken?: 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, + }; } -export const createFnAgent = _createFnAgent as unknown as ( - options: PiAgentOptions, -) => Promise; +export async function promptGateway( + session: GatewaySession, + _prompt: string, + options?: GatewayCallbacks, +): Promise { + const callbacks = options ?? session.callbacks ?? {}; -export const promptWithFallback = _promptWithFallback as unknown as ( - session: PiAgentSession, - prompt: string, - options?: unknown, -) => Promise; + 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, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `OpenClaw gateway request failed (${response.status} ${response.statusText})${body ? `: ${body}` : ""}`, + ); + } + + if (!response.body) { + throw new Error("OpenClaw gateway returned an empty response body"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + let buffer = ""; + let assistantResponse = ""; + + const toolArgBuffers = new Map(); + const toolNames = new Map(); + const toolStarted = new Set(); + const parsedToolArgs = new Map(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + let boundaryIndex = buffer.indexOf("\n\n"); + while (boundaryIndex !== -1) { + const eventChunk = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + 2); + + const lines = eventChunk + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:")); + + for (const line of lines) { + const payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]") { + continue; + } + + 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. + } + } + } + } + + boundaryIndex = buffer.indexOf("\n\n"); + } + } + + const remainder = decoder.decode(); + if (remainder) { + buffer += remainder; + } + + 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); + } + + session.messages.push({ role: "assistant", content: assistantResponse }); + return assistantResponse; +} + +export function describeGatewayModel(session: GatewaySession): string { + return `openclaw/${session.agentId}`; +} -export const describeModel = _describeModel as unknown as (session: PiAgentSession) => string; diff --git a/plugins/fusion-plugin-openclaw-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-openclaw-runtime/src/runtime-adapter.ts index 46451bf76..ca7d06788 100644 --- a/plugins/fusion-plugin-openclaw-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-openclaw-runtime/src/runtime-adapter.ts @@ -1,49 +1,58 @@ import type { AgentRuntime, AgentRuntimeOptions, - AgentSession, AgentSessionResult, + GatewayConfig, + GatewaySession, } from "./types.js"; -import { createFnAgent, describeModel, promptWithFallback } from "./pi-module.js"; - -const getModelDescription = describeModel; +import { + createGatewaySession, + describeGatewayModel, + promptGateway, + resolveGatewayConfig, +} from "./pi-module.js"; export class OpenClawRuntimeAdapter implements AgentRuntime { readonly id = "openclaw"; readonly name = "OpenClaw Runtime"; + private readonly config: GatewayConfig; + + constructor(settings?: Partial) { + this.config = resolveGatewayConfig(settings as Record | undefined); + } + async createSession(options: AgentRuntimeOptions): Promise { - return createFnAgent({ - cwd: options.cwd, + const session = createGatewaySession({ + gatewayUrl: this.config.gatewayUrl, + gatewayToken: this.config.gatewayToken, + agentId: this.config.agentId, systemPrompt: options.systemPrompt, - tools: options.tools, - customTools: options.customTools, - onText: options.onText, - onThinking: options.onThinking, - onToolStart: options.onToolStart, - onToolEnd: options.onToolEnd, - defaultProvider: options.defaultProvider, - defaultModelId: options.defaultModelId, - fallbackProvider: options.fallbackProvider, - fallbackModelId: options.fallbackModelId, - defaultThinkingLevel: options.defaultThinkingLevel, - sessionManager: options.sessionManager, - skillSelection: options.skillSelection, - skills: options.skills, + callbacks: { + onText: options.onText, + onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, + }, }); + + return { + session, + sessionFile: undefined, + }; } - async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise { - return promptWithFallback(session, prompt, options); + async promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise { + session.messages.push({ role: "user", content: prompt }); + + await promptGateway(session, prompt, options as Parameters[2]); } - describeModel(session: AgentSession): string { - return getModelDescription(session); + describeModel(session: GatewaySession): string { + return describeGatewayModel(session); } - async dispose(session: AgentSession): Promise { - if (typeof (session as { dispose?: () => Promise }).dispose === "function") { - await (session as { dispose: () => Promise }).dispose(); - } + async dispose(_session: GatewaySession): Promise { + // OpenClaw gateway sessions are managed remotely; no local cleanup required. } } diff --git a/plugins/fusion-plugin-openclaw-runtime/src/types.ts b/plugins/fusion-plugin-openclaw-runtime/src/types.ts index 498a580ee..7dbc349d3 100644 --- a/plugins/fusion-plugin-openclaw-runtime/src/types.ts +++ b/plugins/fusion-plugin-openclaw-runtime/src/types.ts @@ -1,25 +1,42 @@ /** - * OpenClaw Runtime Plugin - Type Definitions + * OpenClaw runtime adapter contracts. * - * The runtime contract is defined locally to avoid compile-time coupling to - * internal engine exports. + * These mirror the engine runtime interface while keeping this plugin package + * decoupled from internal engine modules. */ -/** Minimal session shape used by the runtime adapter. */ -export interface AgentSession { +export type GatewayRole = "developer" | "user" | "assistant"; + +export interface GatewayMessage { + role: GatewayRole; + content: string; +} + +export interface GatewayConfig { + gatewayUrl: string; + gatewayToken?: string; + agentId: string; +} + +export interface GatewayCallbacks { + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; +} + +export interface GatewaySession extends GatewayConfig { + sessionId: string; + messages: GatewayMessage[]; + callbacks?: GatewayCallbacks; dispose?: () => Promise | void; } -/** Options for creating an agent session. Mirrors createFnAgent inputs used by the adapter. */ -export interface AgentRuntimeOptions { +export interface AgentRuntimeOptions extends GatewayCallbacks { cwd: string; systemPrompt: string; tools?: unknown; customTools?: unknown; - onText?: (text: string) => void; - onThinking?: (text: string) => void; - onToolStart?: (toolName: string, args?: unknown) => void; - onToolEnd?: (toolName: string, result?: unknown) => void; defaultProvider?: string; defaultModelId?: string; fallbackProvider?: string; @@ -30,18 +47,16 @@ export interface AgentRuntimeOptions { skills?: string[]; } -/** Result of creating a session. */ export interface AgentSessionResult { - session: AgentSession; + session: GatewaySession; sessionFile?: string; } -/** Agent runtime adapter interface. */ export interface AgentRuntime { id: string; name: string; createSession(options: AgentRuntimeOptions): Promise; - promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; - describeModel(session: AgentSession): string; - dispose?(session: AgentSession): Promise; + promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise; + describeModel(session: GatewaySession): string; + dispose?(session: GatewaySession): Promise; } diff --git a/plugins/fusion-plugin-openclaw-runtime/vitest.config.ts b/plugins/fusion-plugin-openclaw-runtime/vitest.config.ts index 80063e286..c4778537b 100644 --- a/plugins/fusion-plugin-openclaw-runtime/vitest.config.ts +++ b/plugins/fusion-plugin-openclaw-runtime/vitest.config.ts @@ -1,39 +1,14 @@ -import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10); const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2)); process.env.VITEST_MAX_WORKERS = String(maxWorkers); -const engineGuardStub = fileURLToPath(new URL("./src/__tests__/engine-guard-stub.ts", import.meta.url)); - export default defineConfig({ - resolve: { - alias: { - "@fusion/engine": fileURLToPath(new URL("../../packages/engine/src/index.ts", import.meta.url)), - }, - }, test: { include: ["src/**/*.test.ts"], pool: "threads", maxWorkers, poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, - // ── Engine guard ────────────────────────────────────────────────────── - // This setup file installs a vi.mock("@fusion/engine") that throws if - // the real engine is loaded. All plugin tests must mock "../pi-module.js" - // (the seam) to prevent the real @fusion/engine import chain. - // - // If you introduce a test that genuinely needs @fusion/engine: - // 1. Create a setup-test-isolation.ts (HOME override) following the - // pattern in packages/core/src/__tests__/setup-test-isolation.ts - // 2. Add it to setupFiles BEFORE this guard - // 3. Add a vi.mock("@fusion/engine", ...) override in the test file - // or a dedicated setup file to replace the throwing mock - setupFiles: ["./src/__tests__/setup-engine-guard.ts"], - alias: { - // Resolve @fusion/engine to a local test stub so Vitest can register the - // guard mock without requiring built dist artifacts from packages/engine. - "@fusion/engine": engineGuardStub, - }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cd4d8aa1..fd2f80da3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,9 +537,6 @@ importers: plugins/fusion-plugin-openclaw-runtime: dependencies: - '@fusion/engine': - specifier: workspace:* - version: link:../../packages/engine '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk