feat(FN-2701): route OpenClaw runtime sessions through gateway client
- Add a dedicated gateway client seam and wire runtime adapter sessions through the new request path - Remove legacy engine-guard/pi seam exports and align runtime types with gateway-facing behavior - Fix gateway request/stream handling by preventing duplicate user turns, stabilizing tool-call callbacks, and adding a no-op session dispose hook - Expand plugin test coverage for gateway client, adapter, and index behavior and document runtime gateway behavior in the README
This commit is contained in:
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const event of events) {
|
||||
controller.enqueue(encoder.encode(event));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway client", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("createGatewaySession includes a no-op dispose handler", () => {
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "token",
|
||||
agentId: "main",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
|
||||
expect(typeof session.dispose).toBe("function");
|
||||
expect(() => session.dispose?.()).not.toThrow();
|
||||
});
|
||||
|
||||
it("resolves config from settings first, then env, then defaults", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "http://env-gateway:18789";
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
|
||||
process.env.OPENCLAW_AGENT_ID = "env-agent";
|
||||
|
||||
expect(
|
||||
resolveGatewayConfig({
|
||||
gatewayUrl: "http://settings-gateway:18789",
|
||||
gatewayToken: "settings-token",
|
||||
agentId: "settings-agent",
|
||||
}),
|
||||
).toEqual({
|
||||
gatewayUrl: "http://settings-gateway:18789",
|
||||
gatewayToken: "settings-token",
|
||||
agentId: "settings-agent",
|
||||
});
|
||||
|
||||
expect(resolveGatewayConfig({})).toEqual({
|
||||
gatewayUrl: "http://env-gateway:18789",
|
||||
gatewayToken: "env-token",
|
||||
agentId: "env-agent",
|
||||
});
|
||||
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_AGENT_ID;
|
||||
|
||||
expect(resolveGatewayConfig({})).toEqual({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: undefined,
|
||||
agentId: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("probeGateway returns true for any reachable HTTP response and false on network failures", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not found", { status: 404 })));
|
||||
await expect(probeGateway("http://127.0.0.1:18789")).resolves.toBe(true);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||
await expect(probeGateway("http://127.0.0.1:18789")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("streams text, thinking, and tool-call events from SSE", async () => {
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
createSseResponse([
|
||||
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"reasoning_content":"internal "}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"lookup","arguments":"{\\"id\\":"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"123}"}}]}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":"world"}}]}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
gatewayToken: "secret",
|
||||
agentId: "main",
|
||||
systemPrompt: "You are helpful",
|
||||
});
|
||||
session.messages.push({ role: "user", content: "Say hello" });
|
||||
|
||||
const result = await promptGateway(session, "Say hello", {
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(result).toBe("Hello world");
|
||||
expect(onText).toHaveBeenCalledTimes(2);
|
||||
expect(onText).toHaveBeenNthCalledWith(1, "Hello ");
|
||||
expect(onText).toHaveBeenNthCalledWith(2, "world");
|
||||
expect(onThinking).toHaveBeenCalledWith("internal ");
|
||||
expect(onToolStart).toHaveBeenCalledWith("lookup");
|
||||
expect(onToolEnd).toHaveBeenCalledWith("lookup", false, { id: 123 });
|
||||
|
||||
const [requestUrl, requestInit] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
expect(requestUrl.toString()).toBe("http://127.0.0.1:18789/v1/chat/completions");
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer secret",
|
||||
"x-openclaw-agent-id": "main",
|
||||
});
|
||||
|
||||
const parsedBody = JSON.parse(String(requestInit.body));
|
||||
expect(parsedBody.model).toBe("openclaw:main");
|
||||
expect(parsedBody.stream).toBe(true);
|
||||
expect(parsedBody.user).toBe(session.sessionId);
|
||||
expect(parsedBody.messages.at(-1)).toEqual({ role: "user", content: "Say hello" });
|
||||
});
|
||||
|
||||
it("handles empty data lines, [DONE], and keeps conversation across calls", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createSseResponse([
|
||||
"data: \n\n",
|
||||
'data: {"choices":[{"delta":{"content":"first"}}]}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSseResponse(['data: {"choices":[{"delta":{"content":" second"}}]}\n\n', "data: [DONE]\n\n"]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
agentId: "main",
|
||||
systemPrompt: "System",
|
||||
});
|
||||
session.messages.push({ role: "user", content: "one" });
|
||||
await promptGateway(session, "one");
|
||||
|
||||
session.messages.push({ role: "user", content: "two" });
|
||||
await promptGateway(session, "two");
|
||||
|
||||
expect(session.messages).toEqual([
|
||||
{ role: "developer", content: "System" },
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "assistant", content: "first" },
|
||||
{ role: "user", content: "two" },
|
||||
{ role: "assistant", content: " second" },
|
||||
]);
|
||||
|
||||
const firstBody = JSON.parse(String((fetchMock.mock.calls[0] as [URL, RequestInit])[1].body));
|
||||
const secondBody = JSON.parse(String((fetchMock.mock.calls[1] as [URL, RequestInit])[1].body));
|
||||
expect(firstBody.messages).toHaveLength(2);
|
||||
expect(secondBody.messages).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("throws descriptive errors for non-200 status, invalid SSE JSON, and connection errors", async () => {
|
||||
const session = createGatewaySession({
|
||||
gatewayUrl: "http://127.0.0.1:18789",
|
||||
agentId: "main",
|
||||
systemPrompt: "System",
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad", { status: 503, statusText: "Service Unavailable" })));
|
||||
await expect(promptGateway(session, "test")).rejects.toThrow(
|
||||
"OpenClaw gateway request failed (503 Service Unavailable): bad",
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createSseResponse(["data: {not-json}\n\n"])));
|
||||
await expect(promptGateway(session, "test")).rejects.toThrow("OpenClaw gateway returned invalid SSE JSON");
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ETIMEDOUT")));
|
||||
await expect(promptGateway(session, "test")).rejects.toThrow("ETIMEDOUT");
|
||||
});
|
||||
});
|
||||
@@ -1,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> = {}): 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");
|
||||
|
||||
@@ -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/<agentId>", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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> | 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<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;
|
||||
|
||||
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<boolean> {
|
||||
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<PiAgentResult>;
|
||||
export async function promptGateway(
|
||||
session: GatewaySession,
|
||||
_prompt: string,
|
||||
options?: GatewayCallbacks,
|
||||
): Promise<string> {
|
||||
const callbacks = options ?? session.callbacks ?? {};
|
||||
|
||||
export const promptWithFallback = _promptWithFallback as unknown as (
|
||||
session: PiAgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
) => Promise<void>;
|
||||
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<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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<GatewayConfig>) {
|
||||
this.config = resolveGatewayConfig(settings as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
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<void> {
|
||||
return promptWithFallback(session, prompt, options);
|
||||
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]);
|
||||
}
|
||||
|
||||
describeModel(session: AgentSession): string {
|
||||
return getModelDescription(session);
|
||||
describeModel(session: GatewaySession): string {
|
||||
return describeGatewayModel(session);
|
||||
}
|
||||
|
||||
async dispose(session: AgentSession): Promise<void> {
|
||||
if (typeof (session as { dispose?: () => Promise<void> }).dispose === "function") {
|
||||
await (session as { dispose: () => Promise<void> }).dispose();
|
||||
}
|
||||
async dispose(_session: GatewaySession): Promise<void> {
|
||||
// OpenClaw gateway sessions are managed remotely; no local cleanup required.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> | 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<AgentSessionResult>;
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise<void>;
|
||||
describeModel(session: GatewaySession): string;
|
||||
dispose?(session: GatewaySession): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user