185 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|