Files
stack/apps/orchestrator/src/api/providers/openclaw/openclaw.provider.spec.ts
Jason Woltje 495d78115e
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(orchestrator): add OpenClaw SSE bridge streaming
2026-03-07 16:20:27 -06:00

272 lines
7.1 KiB
TypeScript

import type { HttpService } from "@nestjs/axios";
import { ServiceUnavailableException } from "@nestjs/common";
import type { AgentProviderConfig } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EncryptionService } from "../../../security/encryption.service";
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
import { OpenClawProvider } from "./openclaw.provider";
describe("OpenClawProvider", () => {
let provider: OpenClawProvider;
let httpService: {
axiosRef: {
get: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
};
};
let encryptionService: {
decryptIfNeeded: ReturnType<typeof vi.fn>;
};
let sseBridge: {
streamSession: ReturnType<typeof vi.fn>;
};
const config: AgentProviderConfig = {
id: "cfg-openclaw-1",
workspaceId: "workspace-1",
name: "openclaw-home",
provider: "openclaw",
gatewayUrl: "https://gateway.example.com/",
credentials: {
apiToken: "enc:token-value",
displayName: "Home OpenClaw",
},
isActive: true,
createdAt: new Date("2026-03-07T15:00:00.000Z"),
updatedAt: new Date("2026-03-07T15:00:00.000Z"),
};
beforeEach(() => {
httpService = {
axiosRef: {
get: vi.fn(),
post: vi.fn(),
},
};
encryptionService = {
decryptIfNeeded: vi.fn().mockReturnValue("plain-token"),
};
sseBridge = {
streamSession: vi.fn(),
};
provider = new OpenClawProvider(
config,
encryptionService as unknown as EncryptionService,
httpService as unknown as HttpService,
sseBridge as unknown as OpenClawSseBridge
);
});
it("maps listSessions from OpenClaw API", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: {
sessions: [
{
id: "session-1",
status: "running",
createdAt: "2026-03-07T15:01:00.000Z",
updatedAt: "2026-03-07T15:02:00.000Z",
},
],
total: 1,
cursor: "next-cursor",
},
});
const result = await provider.listSessions("cursor-1", 25);
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
"https://gateway.example.com/api/sessions",
{
headers: {
Authorization: "Bearer plain-token",
},
params: {
cursor: "cursor-1",
limit: 25,
},
}
);
expect(result).toEqual({
sessions: [
{
id: "session-1",
providerId: "openclaw-home",
providerType: "openclaw",
status: "active",
createdAt: new Date("2026-03-07T15:01:00.000Z"),
updatedAt: new Date("2026-03-07T15:02:00.000Z"),
},
],
total: 1,
cursor: "next-cursor",
});
expect(encryptionService.decryptIfNeeded).toHaveBeenCalledWith("enc:token-value");
});
it("returns null from getSession when OpenClaw returns 404", async () => {
httpService.axiosRef.get.mockRejectedValue({
response: {
status: 404,
},
});
await expect(provider.getSession("missing-session")).resolves.toBeNull();
});
it("maps getMessages response", async () => {
httpService.axiosRef.get.mockResolvedValue({
data: {
messages: [
{
id: "message-1",
sessionId: "session-1",
role: "agent",
content: "hello",
timestamp: "2026-03-07T15:03:00.000Z",
metadata: {
tokens: 128,
},
},
],
},
});
const result = await provider.getMessages("session-1", 20, "before-cursor");
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
"https://gateway.example.com/api/messages",
{
headers: {
Authorization: "Bearer plain-token",
},
params: {
sessionId: "session-1",
limit: 20,
before: "before-cursor",
},
}
);
expect(result).toEqual([
{
id: "message-1",
sessionId: "session-1",
role: "assistant",
content: "hello",
timestamp: new Date("2026-03-07T15:03:00.000Z"),
metadata: {
tokens: 128,
},
},
]);
});
it("maps inject and control endpoints", async () => {
httpService.axiosRef.post
.mockResolvedValueOnce({
data: {
accepted: true,
messageId: "message-2",
},
})
.mockResolvedValueOnce({ data: {} })
.mockResolvedValueOnce({ data: {} })
.mockResolvedValueOnce({ data: {} });
await expect(provider.injectMessage("session-1", "barge in")).resolves.toEqual({
accepted: true,
messageId: "message-2",
});
await provider.pauseSession("session-1");
await provider.resumeSession("session-1");
await provider.killSession("session-1", false);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
1,
"https://gateway.example.com/api/sessions/session-1/inject",
{ content: "barge in" },
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
2,
"https://gateway.example.com/api/sessions/session-1/pause",
{},
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
3,
"https://gateway.example.com/api/sessions/session-1/resume",
{},
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
4,
"https://gateway.example.com/api/sessions/session-1/kill",
{ force: false },
{
headers: {
Authorization: "Bearer plain-token",
},
}
);
});
it("delegates streaming to OpenClawSseBridge", async () => {
const streamedMessage = {
id: "message-stream",
sessionId: "session-stream",
role: "assistant",
content: "stream hello",
timestamp: new Date("2026-03-07T16:00:00.000Z"),
};
sseBridge.streamSession.mockReturnValue(
(async function* () {
yield streamedMessage;
})()
);
const messages: Array<unknown> = [];
for await (const message of provider.streamMessages("session-stream")) {
messages.push(message);
}
expect(sseBridge.streamSession).toHaveBeenCalledWith(
"https://gateway.example.com",
"session-stream",
{
Authorization: "Bearer plain-token",
}
);
expect(messages).toEqual([streamedMessage]);
});
it("throws ServiceUnavailableException for request failures", async () => {
httpService.axiosRef.get.mockRejectedValue(new Error("gateway unreachable"));
await expect(provider.listSessions()).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it("returns false from isAvailable when gateway check fails", async () => {
httpService.axiosRef.get.mockRejectedValue(new Error("gateway unreachable"));
await expect(provider.isAvailable()).resolves.toBe(false);
});
});