paperclip/packages/adapters/openclaw-gateway/src/server/execute-dispatch.test.ts

185 lines
5.2 KiB
TypeScript

import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const websocketState = vi.hoisted(() => ({
connectionAttempts: 0,
failConnectAttempts: 0,
failAgentRequests: 0,
events: [] as string[],
}));
vi.mock("ws", async () => {
const { EventEmitter } = await import("node:events");
class FakeWebSocket extends EventEmitter {
static readonly OPEN = 1;
readonly readyState = FakeWebSocket.OPEN;
readonly attempt: number;
constructor() {
super();
this.attempt = ++websocketState.connectionAttempts;
websocketState.events.push(`construct:${this.attempt}`);
queueMicrotask(() => {
if (this.attempt <= websocketState.failConnectAttempts) {
this.emit("error", new Error("ECONNREFUSED"));
return;
}
this.emit("open");
this.emit("message", JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "test-nonce" },
}));
});
}
send(payload: string) {
const request = JSON.parse(payload) as { id: string; method: string };
websocketState.events.push(`send:${request.method}`);
if (request.method === "agent" && websocketState.failAgentRequests > 0) {
websocketState.failAgentRequests--;
queueMicrotask(() => {
this.emit("close", 1006, Buffer.from("ECONNRESET"));
});
return;
}
const responsePayload = request.method === "connect"
? { protocol: 3 }
: { status: "ok", runId: "remote-run-1", summary: "done" };
queueMicrotask(() => {
this.emit("message", JSON.stringify({
type: "res",
id: request.id,
ok: true,
payload: responsePayload,
}));
});
}
close() {}
}
return { WebSocket: FakeWebSocket };
});
import { execute } from "./execute.js";
function createContext(input: {
onDispatch?: () => void;
onLog?: AdapterExecutionContext["onLog"];
} = {}): AdapterExecutionContext {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "OpenClaw Agent",
adapterType: "openclaw_gateway",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
url: "ws://127.0.0.1:18789",
disableDeviceAuth: true,
timeoutSec: 1,
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "interaction_resolved",
},
onLog: input.onLog ?? (async () => {}),
onDispatch: input.onDispatch,
};
}
describe("openclaw_gateway execute dispatch boundary", () => {
beforeEach(() => {
websocketState.connectionAttempts = 0;
websocketState.failConnectAttempts = 0;
websocketState.failAgentRequests = 0;
websocketState.events = [];
});
afterEach(() => {
vi.useRealTimers();
});
it("reports dispatch after transport setup and before the remote agent request", async () => {
const onDispatch = vi.fn(() => {
websocketState.events.push("dispatch");
});
const result = await execute(createContext({ onDispatch }));
expect(result).toMatchObject({ exitCode: 0 });
expect(onDispatch).toHaveBeenCalledTimes(1);
expect(websocketState.events).toEqual([
"construct:1",
"send:connect",
"dispatch",
"send:agent",
]);
});
it("retains the continuation gate through transient connection backoff", async () => {
vi.useFakeTimers();
websocketState.failConnectAttempts = 1;
let resolveBackoff!: () => void;
const backoffReached = new Promise<void>((resolve) => {
resolveBackoff = resolve;
});
let resolveAuthorityChange!: () => void;
const authorityChange = new Promise<void>((resolve) => {
resolveAuthorityChange = resolve;
});
const onDispatch = vi.fn(resolveAuthorityChange);
const resultPromise = execute(createContext({
onDispatch,
onLog: async (_stream, chunk) => {
if (chunk.includes("transient error, retry")) resolveBackoff();
},
}));
await backoffReached;
expect(websocketState.connectionAttempts).toBe(1);
expect(onDispatch).not.toHaveBeenCalled();
let authorityChangeSettled = false;
void authorityChange.then(() => {
authorityChangeSettled = true;
});
await vi.advanceTimersByTimeAsync(0);
expect(authorityChangeSettled).toBe(false);
await vi.advanceTimersByTimeAsync(2_000);
const result = await resultPromise;
await authorityChange;
expect(result).toMatchObject({ exitCode: 0 });
expect(websocketState.connectionAttempts).toBe(2);
expect(onDispatch).toHaveBeenCalledTimes(1);
expect(authorityChangeSettled).toBe(true);
});
it("does not retry after the remote-work boundary has been crossed", async () => {
websocketState.failAgentRequests = 1;
const onDispatch = vi.fn();
const result = await execute(createContext({ onDispatch }));
expect(result).toMatchObject({
exitCode: 1,
errorCode: "openclaw_gateway_request_failed",
});
expect(websocketState.connectionAttempts).toBe(1);
expect(onDispatch).toHaveBeenCalledTimes(1);
});
});