diff --git a/packages/sdk/src/index.e2e.test.ts b/packages/sdk/src/index.e2e.test.ts index 468627e0ab3..8bcb1293e23 100644 --- a/packages/sdk/src/index.e2e.test.ts +++ b/packages/sdk/src/index.e2e.test.ts @@ -7,6 +7,16 @@ import { emitAgentEvent, registerAgentRunContext } from "../../../src/infra/agen import { GatewayClientTransport, OpenClaw } from "./index.js"; type JsonObject = Record; +type FakeGatewayRequest = { + id: string; + method: string; + params?: unknown; +}; +type FakeGateway = { + url: string; + requests: FakeGatewayRequest[]; + close: () => Promise; +}; const servers: WebSocketServer[] = []; @@ -37,13 +47,17 @@ async function reservePort(): Promise { return port; } -async function createFakeGateway(port = 0): Promise<{ url: string; close: () => Promise }> { +async function createFakeGateway(port = 0): Promise { const server = new WebSocketServer({ host: "127.0.0.1", port }); servers.push(server); await new Promise((resolve) => server.once("listening", resolve)); let seq = 1; + const requests: FakeGatewayRequest[] = []; + const sockets = new Set(); server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); sendJson(socket, { type: "event", event: "connect.challenge", @@ -52,56 +66,84 @@ async function createFakeGateway(port = 0): Promise<{ url: string; close: () => }); socket.on("message", (raw) => { - const frame = JSON.parse(readRawMessage(raw)) as { - id: string; - method: string; - params?: unknown; + const frame = JSON.parse(readRawMessage(raw)) as FakeGatewayRequest; + requests.push(frame); + const reply = (payload: JsonObject): void => { + sendJson(socket, { type: "res", id: frame.id, ok: true, payload }); }; if (frame.method === "connect") { - sendJson(socket, { - type: "res", - id: frame.id, - ok: true, - payload: { - type: "hello-ok", - protocol: 1, - server: { version: "sdk-e2e", connId: "conn-sdk-e2e" }, - features: { - methods: [ - "agent", - "agent.wait", - "connect", - "sessions.abort", - "sessions.create", - "sessions.send", - ], - events: ["agent", "sessions.changed"], - }, - snapshot: { - presence: [], - health: {}, - stateVersion: { presence: 0, health: 0 }, - uptimeMs: 1, - }, - auth: { role: "operator", scopes: [] }, - policy: { - maxPayload: 262144, - maxBufferedBytes: 262144, - tickIntervalMs: 30000, - }, + reply({ + type: "hello-ok", + protocol: 1, + server: { version: "sdk-e2e", connId: "conn-sdk-e2e" }, + features: { + methods: [ + "agent", + "agent.wait", + "agent.identity.get", + "agents.create", + "agents.delete", + "agents.list", + "agents.update", + "connect", + "exec.approval.list", + "exec.approval.resolve", + "models.authStatus", + "models.list", + "sessions.abort", + "sessions.create", + "sessions.compact", + "sessions.list", + "sessions.patch", + "sessions.resolve", + "sessions.send", + "tools.catalog", + "tools.effective", + ], + events: ["agent", "sessions.changed"], + }, + snapshot: { + presence: [], + health: {}, + stateVersion: { presence: 0, health: 0 }, + uptimeMs: 1, + }, + auth: { role: "operator", scopes: [] }, + policy: { + maxPayload: 262144, + maxBufferedBytes: 262144, + tickIntervalMs: 30000, }, }); return; } + if (frame.method === "agents.list") { + reply({ agents: [{ id: "main" }] }); + return; + } + + if (frame.method === "agent.identity.get") { + reply({ agentId: "main", ...(frame.params as JsonObject | undefined) }); + return; + } + + if ( + frame.method === "agents.create" || + frame.method === "agents.update" || + frame.method === "agents.delete" + ) { + reply({ ok: true, method: frame.method, params: frame.params as JsonObject | undefined }); + return; + } + if (frame.method === "agent") { const params = frame.params as { sessionKey?: string } | undefined; - sendJson(socket, { - type: "res", - id: frame.id, - ok: true, - payload: { status: "accepted", runId: "run-sdk-e2e", sessionKey: params?.sessionKey }, + reply({ + status: "accepted", + runId: "run-sdk-e2e", + sessionKey: params?.sessionKey, }); setTimeout(() => { sendJson(socket, { @@ -145,43 +187,104 @@ async function createFakeGateway(port = 0): Promise<{ url: string; close: () => } if (frame.method === "agent.wait") { - sendJson(socket, { - type: "res", - id: frame.id, - ok: true, - payload: { - status: "ok", - runId: "run-sdk-e2e", - sessionKey: "main", - startedAt: 123, - endedAt: 456, - }, + reply({ + status: "ok", + runId: "run-sdk-e2e", + sessionKey: "main", + startedAt: 123, + endedAt: 456, }); + return; + } + + if (frame.method === "sessions.list") { + reply({ sessions: [{ key: "sdk-session" }] }); + return; + } + + if (frame.method === "sessions.create") { + const params = frame.params as { key?: string } | undefined; + reply({ key: params?.key ?? "sdk-session" }); + return; + } + + if (frame.method === "sessions.resolve") { + reply({ key: "sdk-session", params: frame.params as JsonObject | undefined }); + return; + } + + if (frame.method === "sessions.send") { + const params = frame.params as { key?: string } | undefined; + reply({ status: "ok", runId: "run-session-e2e", sessionKey: params?.key }); + return; } if (frame.method === "sessions.abort") { - sendJson(socket, { - type: "res", - id: frame.id, + reply({ ok: true, - payload: { - ok: true, - abortedRunId: "run-sdk-e2e", - status: "aborted", - }, + abortedRunId: (frame.params as { runId?: string } | undefined)?.runId ?? "run-sdk-e2e", + status: "aborted", }); + return; } + + if (frame.method === "sessions.patch" || frame.method === "sessions.compact") { + reply({ ok: true, method: frame.method, params: frame.params as JsonObject | undefined }); + return; + } + + if (frame.method === "models.list") { + reply({ models: [{ id: "gpt-5.4" }] }); + return; + } + + if (frame.method === "models.authStatus") { + reply({ providers: [] }); + return; + } + + if (frame.method === "tools.catalog") { + reply({ tools: [{ name: "shell" }] }); + return; + } + + if (frame.method === "tools.effective") { + reply({ tools: [{ name: "shell", enabled: true }] }); + return; + } + + if (frame.method === "exec.approval.list") { + reply({ approvals: [] }); + return; + } + + if (frame.method === "exec.approval.resolve") { + reply({ ok: true, params: frame.params as JsonObject | undefined }); + return; + } + + sendJson(socket, { + type: "res", + id: frame.id, + ok: false, + error: { code: "UNKNOWN_METHOD", message: `unhandled fake Gateway method ${frame.method}` }, + }); }); }); const { port: boundPort } = server.address() as AddressInfo; return { url: `ws://127.0.0.1:${boundPort}`, + requests, close: () => { const index = servers.indexOf(server); if (index >= 0) { servers.splice(index, 1); } + for (const socket of sockets) { + socket.terminate(); + } + sockets.clear(); return new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); @@ -195,6 +298,9 @@ describe("OpenClaw SDK websocket e2e", () => { servers.splice(0).map( (server) => new Promise((resolve) => { + for (const client of server.clients) { + client.terminate(); + } server.close(() => resolve()); }), ), @@ -256,6 +362,90 @@ describe("OpenClaw SDK websocket e2e", () => { } }); + it("covers documented namespace helpers over a Gateway websocket", async () => { + const gateway = await createFakeGateway(); + const transport = new GatewayClientTransport({ + url: gateway.url, + deviceIdentity: null, + requestTimeoutMs: 2_000, + }); + const oc = new OpenClaw({ transport }); + + try { + await expect(oc.agents.list()).resolves.toMatchObject({ agents: [{ id: "main" }] }); + const agent = await oc.agents.get("main"); + await expect(agent.identity({ sessionKey: "sdk-session" })).resolves.toMatchObject({ + agentId: "main", + sessionKey: "sdk-session", + }); + await expect(oc.agents.create({ id: "sdk-agent" })).resolves.toMatchObject({ + method: "agents.create", + }); + await expect( + oc.agents.update({ id: "sdk-agent", label: "SDK Agent" }), + ).resolves.toMatchObject({ method: "agents.update" }); + await expect(oc.agents.delete({ id: "sdk-agent" })).resolves.toMatchObject({ + method: "agents.delete", + }); + + await expect(oc.sessions.list()).resolves.toMatchObject({ + sessions: [{ key: "sdk-session" }], + }); + const session = await oc.sessions.create({ key: "sdk-session", agentId: "main" }); + expect(session.key).toBe("sdk-session"); + await expect(oc.sessions.resolve({ key: "sdk-session" })).resolves.toMatchObject({ + key: "sdk-session", + }); + const sessionRun = await session.send("continue"); + expect(sessionRun.id).toBe("run-session-e2e"); + await expect(session.abort(sessionRun.id)).resolves.toMatchObject({ + abortedRunId: "run-session-e2e", + }); + await expect(session.patch({ label: "Renamed" })).resolves.toMatchObject({ + method: "sessions.patch", + }); + await expect(session.compact({ maxLines: 200 })).resolves.toMatchObject({ + method: "sessions.compact", + }); + + await expect(oc.models.list()).resolves.toMatchObject({ models: [{ id: "gpt-5.4" }] }); + await expect(oc.models.status({ probe: false })).resolves.toMatchObject({ providers: [] }); + await expect(oc.tools.list()).resolves.toMatchObject({ tools: [{ name: "shell" }] }); + await expect(oc.tools.effective({ sessionKey: "sdk-session" })).resolves.toMatchObject({ + tools: [{ name: "shell", enabled: true }], + }); + await expect(oc.approvals.list()).resolves.toMatchObject({ approvals: [] }); + await expect( + oc.approvals.respond("approval-1", { decision: "approve" }), + ).resolves.toMatchObject({ ok: true }); + + expect(gateway.requests.map((request) => request.method)).toEqual([ + "connect", + "agents.list", + "agent.identity.get", + "agents.create", + "agents.update", + "agents.delete", + "sessions.list", + "sessions.create", + "sessions.resolve", + "sessions.send", + "sessions.abort", + "sessions.patch", + "sessions.compact", + "models.list", + "models.authStatus", + "tools.catalog", + "tools.effective", + "exec.approval.list", + "exec.approval.resolve", + ]); + } finally { + await oc.close(); + await gateway.close(); + } + }, 10_000); + it("retries after an initial websocket connection failure", async () => { const port = await reservePort(); const url = `ws://127.0.0.1:${port}`; @@ -349,3 +539,81 @@ describe("OpenClaw SDK real Gateway e2e", () => { } }); }); + +const liveGatewayUrl = process.env.OPENCLAW_SDK_LIVE_GATEWAY_URL; +const liveGatewayToken = process.env.OPENCLAW_SDK_LIVE_GATEWAY_TOKEN; +const liveGatewayDescribe = liveGatewayUrl && liveGatewayToken ? describe : describe.skip; + +function readLiveTextDelta(data: unknown): string { + if (!data || typeof data !== "object") { + return ""; + } + const record = data as Record; + for (const key of ["delta", "text", "content"]) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; +} + +liveGatewayDescribe("OpenClaw SDK live Gateway e2e", () => { + it("connects to a configured Gateway, streams a real run, and waits for completion", async () => { + const oc = new OpenClaw({ + url: liveGatewayUrl, + token: liveGatewayToken, + requestTimeoutMs: 20_000, + }); + + try { + await oc.connect(); + await expect(oc.agents.list()).resolves.toBeDefined(); + await expect(oc.models.status({ probe: false })).resolves.toBeDefined(); + + const agent = await oc.agents.get(process.env.OPENCLAW_SDK_LIVE_AGENT_ID ?? "main"); + const run = await agent.run({ + input: "Reply with exactly: OPENCLAW_SDK_LIVE_OK", + sessionKey: `sdk-live-e2e-${Date.now()}`, + deliver: false, + timeoutMs: 120_000, + label: "SDK live E2E", + }); + + const eventsPromise = (async () => { + const eventTypes: string[] = []; + let text = ""; + for await (const event of run.events()) { + eventTypes.push(event.type); + if (event.type === "assistant.delta" || event.type === "assistant.message") { + text += readLiveTextDelta(event.data); + } + if ( + event.type === "run.completed" || + event.type === "run.failed" || + event.type === "run.cancelled" || + event.type === "run.timed_out" + ) { + return { eventTypes, terminal: event.type, text }; + } + } + return { eventTypes, terminal: undefined, text }; + })(); + + const result = await run.wait({ timeoutMs: 180_000 }); + const events = await Promise.race([ + eventsPromise, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("timed out waiting for live SDK run events")), 5_000); + }), + ]); + + expect(result.status).toBe("completed"); + expect(events.terminal).toBe("run.completed"); + expect(events.eventTypes).toContain("run.started"); + expect(events.text).toContain("OPENCLAW_SDK_LIVE_OK"); + } finally { + await oc.close(); + } + }, 240_000); +}); diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index f23b7400327..95231744bef 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -255,9 +255,24 @@ describe("OpenClaw SDK", () => { await expect(oc.artifacts.list()).rejects.toThrow( "oc.artifacts.list is not supported by the current OpenClaw Gateway yet", ); + await expect(oc.artifacts.get("artifact_123")).rejects.toThrow( + "oc.artifacts.get is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.artifacts.download("artifact_123")).rejects.toThrow( + "oc.artifacts.download is not supported by the current OpenClaw Gateway yet", + ); await expect(oc.environments.list()).rejects.toThrow( "oc.environments.list is not supported by the current OpenClaw Gateway yet", ); + await expect(oc.environments.create({ provider: "testbox" })).rejects.toThrow( + "oc.environments.create is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.environments.status("environment_123")).rejects.toThrow( + "oc.environments.status is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.environments.delete("environment_123")).rejects.toThrow( + "oc.environments.delete is not supported by the current OpenClaw Gateway yet", + ); expect(transport.calls).toEqual([]); });