Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
971
packages/sdk/src/client.ts
Normal file
971
packages/sdk/src/client.ts
Normal file
@@ -0,0 +1,971 @@
|
||||
// OpenClaw SDK module implements client behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import { normalizeGatewayEvent } from "./normalize.js";
|
||||
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
|
||||
import type {
|
||||
AgentsCreateParams,
|
||||
AgentsDeleteParams,
|
||||
AgentsUpdateParams,
|
||||
AgentRunParams,
|
||||
ApprovalDecisionParams,
|
||||
ArtifactQuery,
|
||||
ArtifactsDownloadResult,
|
||||
ArtifactsGetResult,
|
||||
ArtifactsListResult,
|
||||
EnvironmentSummary,
|
||||
EnvironmentsListResult,
|
||||
GatewayEvent,
|
||||
GatewayRequestOptions,
|
||||
OpenClawEvent,
|
||||
OpenClawTransport,
|
||||
RunCreateParams,
|
||||
RunResult,
|
||||
RunTimestamp,
|
||||
SessionCreateParams,
|
||||
SessionSendParams,
|
||||
SessionTarget,
|
||||
TasksCancelResult,
|
||||
TasksGetResult,
|
||||
TasksListParams,
|
||||
TasksListResult,
|
||||
ToolsEffectiveParams,
|
||||
ToolInvokeParams,
|
||||
ToolInvokeResult,
|
||||
} from "./types.js";
|
||||
|
||||
// High-level OpenClaw SDK client. Namespaces below translate friendly SDK calls
|
||||
// into current Gateway RPC methods and normalize event streams for consumers.
|
||||
const MAX_REPLAY_RUNS = 100;
|
||||
const MAX_REPLAY_EVENTS_PER_RUN = 500;
|
||||
const MAX_NORMALIZED_REPLAY_EVENTS = 2000;
|
||||
|
||||
/** Connection and transport options for the OpenClaw SDK client. */
|
||||
export type OpenClawOptions = {
|
||||
gateway?: "auto" | (string & {});
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
requestTimeoutMs?: number;
|
||||
transport?: OpenClawTransport;
|
||||
};
|
||||
|
||||
function resolveGatewayUrl(options: OpenClawOptions): string | undefined {
|
||||
if (options.url) {
|
||||
return options.url;
|
||||
}
|
||||
if (options.gateway && options.gateway !== "auto") {
|
||||
return options.gateway;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runStatusFromWaitPayload(payload: unknown): RunResult["status"] {
|
||||
// Gateway wait payloads come from several runtime paths. Preserve timeout vs
|
||||
// cancellation semantics from metadata instead of trusting one status field.
|
||||
const record =
|
||||
typeof payload === "object" && payload !== null
|
||||
? (payload as Record<string, unknown> & { aborted?: unknown; status?: unknown })
|
||||
: {};
|
||||
const status = typeof record.status === "string" ? record.status.toLowerCase() : undefined;
|
||||
const stopReason = typeof record.stopReason === "string" ? record.stopReason.toLowerCase() : "";
|
||||
const pendingError = record.pendingError === true;
|
||||
const timeoutPhase =
|
||||
typeof record.timeoutPhase === "string" ? record.timeoutPhase.toLowerCase() : undefined;
|
||||
const statusAlreadyTimeoutAttributed = status === "timeout" || status === "timed_out";
|
||||
const hardTimeout =
|
||||
!pendingError &&
|
||||
((stopReason !== "restart" &&
|
||||
record.providerStarted === true &&
|
||||
statusAlreadyTimeoutAttributed) ||
|
||||
timeoutPhase === "preflight" ||
|
||||
timeoutPhase === "provider" ||
|
||||
timeoutPhase === "post_turn");
|
||||
const hasTerminalTimeoutMetadata =
|
||||
readOptionalTimestamp(record.endedAt) !== undefined ||
|
||||
(!pendingError && readOptionalString(record.error) !== undefined) ||
|
||||
stopReason.length > 0 ||
|
||||
typeof record.livenessState === "string" ||
|
||||
record.yielded === true;
|
||||
if (hardTimeout) {
|
||||
return "timed_out";
|
||||
}
|
||||
if (
|
||||
status === "aborted" ||
|
||||
status === "cancelled" ||
|
||||
status === "canceled" ||
|
||||
status === "killed" ||
|
||||
stopReason === "aborted" ||
|
||||
stopReason === "cancelled" ||
|
||||
stopReason === "canceled" ||
|
||||
stopReason === "killed" ||
|
||||
stopReason === "auth-revoked" ||
|
||||
stopReason === "restart" ||
|
||||
stopReason === "rpc" ||
|
||||
stopReason === "user" ||
|
||||
(record.aborted === true && stopReason === "stop")
|
||||
) {
|
||||
return "cancelled";
|
||||
}
|
||||
if (status === "ok" || status === "completed" || status === "succeeded") {
|
||||
return "completed";
|
||||
}
|
||||
if (status === "timeout") {
|
||||
if (
|
||||
stopReason === "timeout" ||
|
||||
stopReason === "timed_out" ||
|
||||
record.aborted === true ||
|
||||
hasTerminalTimeoutMetadata
|
||||
) {
|
||||
return "timed_out";
|
||||
}
|
||||
return "accepted";
|
||||
}
|
||||
if (status === "timed_out") {
|
||||
return "timed_out";
|
||||
}
|
||||
if (status === "accepted") {
|
||||
return "accepted";
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readOptionalTimestamp(value: unknown): RunTimestamp | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
|
||||
if (timeoutMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
||||
throw new Error("timeoutMs must be a finite non-negative number");
|
||||
}
|
||||
return Math.floor(timeoutMs);
|
||||
}
|
||||
|
||||
function timeoutSecondsFromMs(timeoutMs: number | undefined): number | undefined {
|
||||
const normalized = normalizeTimeoutMs(timeoutMs);
|
||||
if (normalized === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === 0 ? 0 : Math.ceil(normalized / 1000);
|
||||
}
|
||||
|
||||
function splitModelRef(model: string | undefined): { provider?: string; model?: string } {
|
||||
if (!model) {
|
||||
return {};
|
||||
}
|
||||
const index = model.indexOf("/");
|
||||
if (index <= 0 || index === model.length - 1) {
|
||||
return { model };
|
||||
}
|
||||
return {
|
||||
provider: model.slice(0, index),
|
||||
model: model.slice(index + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoUnsupportedRunOptions(params: AgentRunParams): void {
|
||||
const unsupported = [
|
||||
params.workspace ? "workspace" : undefined,
|
||||
params.runtime ? "runtime" : undefined,
|
||||
params.environment ? "environment" : undefined,
|
||||
params.approvals ? "approvals" : undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (unsupported.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`OpenClaw Gateway does not support per-run SDK option${
|
||||
unsupported.length === 1 ? "" : "s"
|
||||
} yet: ${unsupported.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAgentParams(params: AgentRunParams): Record<string, unknown> {
|
||||
assertNoUnsupportedRunOptions(params);
|
||||
const modelRef = splitModelRef(params.model);
|
||||
const timeoutSeconds = timeoutSecondsFromMs(params.timeoutMs);
|
||||
return {
|
||||
message: params.input,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(modelRef.provider ? { provider: modelRef.provider } : {}),
|
||||
...(modelRef.model ? { model: modelRef.model } : {}),
|
||||
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.thinking ? { thinking: params.thinking } : {}),
|
||||
...(typeof params.deliver === "boolean" ? { deliver: params.deliver } : {}),
|
||||
...(params.attachments ? { attachments: params.attachments } : {}),
|
||||
...(timeoutSeconds !== undefined ? { timeout: timeoutSeconds } : {}),
|
||||
...(params.label ? { label: params.label } : {}),
|
||||
idempotencyKey: params.idempotencyKey ?? randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
function unsupportedGatewayApi(api: string): never {
|
||||
throw new Error(`${api} is not supported by the current OpenClaw Gateway yet`);
|
||||
}
|
||||
|
||||
type ChatProjectionState = "delta" | "final";
|
||||
|
||||
type ChatProjection = {
|
||||
state: ChatProjectionState;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function hasArtifactQueryScope(params: unknown): params is ArtifactQuery {
|
||||
const record = asRecord(params);
|
||||
return [record.sessionKey, record.runId, record.taskId].some(
|
||||
(value) => typeof value === "string" && value.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery {
|
||||
if (!hasArtifactQueryScope(params)) {
|
||||
throw new Error(`${api} requires one of sessionKey, runId, or taskId`);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function hasToolsEffectiveSessionKey(params: unknown): params is ToolsEffectiveParams {
|
||||
const record = asRecord(params);
|
||||
return typeof record.sessionKey === "string" && record.sessionKey.trim().length > 0;
|
||||
}
|
||||
|
||||
function requireToolsEffectiveSessionKey(params: unknown): ToolsEffectiveParams {
|
||||
if (!hasToolsEffectiveSessionKey(params)) {
|
||||
throw new Error("oc.tools.effective requires sessionKey");
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function readChatProjection(event: OpenClawEvent): ChatProjection | undefined {
|
||||
const raw = event.raw;
|
||||
if (event.type !== "raw" || raw?.event !== "chat") {
|
||||
return undefined;
|
||||
}
|
||||
const payload = asRecord(raw.payload);
|
||||
return payload.state === "delta" || payload.state === "final"
|
||||
? { state: payload.state, payload }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readChatProjectionText(payload: Record<string, unknown>): string | undefined {
|
||||
const message = asRecord(payload.message);
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = content
|
||||
.map((part) => {
|
||||
const record = asRecord(part);
|
||||
return record.type === "text" && typeof record.text === "string" ? record.text : "";
|
||||
})
|
||||
.join("");
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function readChatProjectionDeltaText(payload: Record<string, unknown>): string | undefined {
|
||||
return typeof payload.deltaText === "string" ? payload.deltaText : undefined;
|
||||
}
|
||||
|
||||
function readChatProjectionReplace(payload: Record<string, unknown>): boolean {
|
||||
return payload.replace === true;
|
||||
}
|
||||
|
||||
function isAssistantRunEvent(event: OpenClawEvent): boolean {
|
||||
return event.type === "assistant.delta" || event.type === "assistant.message";
|
||||
}
|
||||
|
||||
function isTerminalRunEvent(event: OpenClawEvent): boolean {
|
||||
return (
|
||||
event.type === "run.completed" ||
|
||||
event.type === "run.failed" ||
|
||||
event.type === "run.cancelled" ||
|
||||
event.type === "run.timed_out"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeChatProjectionEvent(
|
||||
event: OpenClawEvent,
|
||||
projection: ChatProjection,
|
||||
previousText: string | undefined,
|
||||
): OpenClawEvent {
|
||||
const text = readChatProjectionText(projection.payload);
|
||||
const deltaText = readChatProjectionDeltaText(projection.payload);
|
||||
const hasPreviousText = previousText !== undefined;
|
||||
const isReplacement = readChatProjectionReplace(projection.payload);
|
||||
return {
|
||||
...event,
|
||||
type: projection.state === "delta" ? "assistant.delta" : "run.completed",
|
||||
data:
|
||||
projection.state === "delta"
|
||||
? text !== undefined
|
||||
? {
|
||||
text,
|
||||
delta: hasPreviousText ? (deltaText ?? text) : text,
|
||||
...(isReplacement ? { replace: true } : {}),
|
||||
}
|
||||
: event.data
|
||||
: { phase: "end", ...(text !== undefined ? { outputText: text } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Root SDK client with namespaces for agents, sessions, runs, and gateway APIs. */
|
||||
export class OpenClaw {
|
||||
readonly agents: AgentsNamespace;
|
||||
readonly sessions: SessionsNamespace;
|
||||
readonly runs: RunsNamespace;
|
||||
readonly tasks: TasksNamespace;
|
||||
readonly models: ModelsNamespace;
|
||||
readonly tools: ToolsNamespace;
|
||||
readonly artifacts: ArtifactsNamespace;
|
||||
readonly approvals: ApprovalsNamespace;
|
||||
readonly environments: EnvironmentsNamespace;
|
||||
|
||||
private readonly transport: OpenClawTransport;
|
||||
private readonly normalizedEvents = new EventHub<OpenClawEvent>({
|
||||
replayLimit: MAX_NORMALIZED_REPLAY_EVENTS,
|
||||
});
|
||||
private readonly replayByRunId = new Map<string, OpenClawEvent[]>();
|
||||
private connected = false;
|
||||
private closed = false;
|
||||
private eventPumpPromise: Promise<void> | null = null;
|
||||
private eventPumpReady: Promise<void> | null = null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(options: OpenClawOptions = {}) {
|
||||
this.transport =
|
||||
options.transport ??
|
||||
new GatewayClientTransport({
|
||||
url: resolveGatewayUrl(options),
|
||||
token: options.token,
|
||||
password: options.password,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
});
|
||||
this.agents = new AgentsNamespace(this);
|
||||
this.sessions = new SessionsNamespace(this);
|
||||
this.runs = new RunsNamespace(this);
|
||||
this.tasks = new TasksNamespace(this);
|
||||
this.models = new ModelsNamespace(this);
|
||||
this.tools = new ToolsNamespace(this);
|
||||
this.artifacts = new ArtifactsNamespace(this);
|
||||
this.approvals = new ApprovalsNamespace(this);
|
||||
this.environments = new EnvironmentsNamespace(this);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.assertOpen();
|
||||
if (this.connected) {
|
||||
await this.startEventPump();
|
||||
this.assertOpen();
|
||||
return;
|
||||
}
|
||||
if (isConnectableTransport(this.transport)) {
|
||||
await this.transport.connect();
|
||||
}
|
||||
this.assertOpen();
|
||||
this.connected = true;
|
||||
await this.startEventPump();
|
||||
this.assertOpen();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closePromise) {
|
||||
return await this.closePromise;
|
||||
}
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.closePromise = (async () => {
|
||||
try {
|
||||
await this.transport.close?.();
|
||||
await this.eventPumpPromise?.catch(() => {});
|
||||
} finally {
|
||||
this.normalizedEvents.close();
|
||||
this.eventPumpPromise = null;
|
||||
this.eventPumpReady = null;
|
||||
this.connected = false;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await this.closePromise;
|
||||
} finally {
|
||||
this.closePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
await this.connect();
|
||||
this.assertOpen();
|
||||
return await this.transport.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
|
||||
return this.iterateEvents(filter);
|
||||
}
|
||||
|
||||
runEvents(
|
||||
runId: string,
|
||||
filter?: (event: OpenClawEvent) => boolean,
|
||||
): AsyncIterable<OpenClawEvent> {
|
||||
return this.iterateRunEvents(runId, filter);
|
||||
}
|
||||
|
||||
rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
|
||||
this.assertOpen();
|
||||
return this.transport.events(filter);
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new Error("OpenClaw SDK client is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private async *iterateEvents(
|
||||
filter?: (event: OpenClawEvent) => boolean,
|
||||
): AsyncIterable<OpenClawEvent> {
|
||||
await this.connect();
|
||||
this.assertOpen();
|
||||
for await (const event of this.normalizedEvents.stream(filter)) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
private async *iterateRunEvents(
|
||||
runId: string,
|
||||
filter?: (event: OpenClawEvent) => boolean,
|
||||
): AsyncIterable<OpenClawEvent> {
|
||||
await this.connect();
|
||||
this.assertOpen();
|
||||
const replayEvents = this.replaySnapshot(runId);
|
||||
let hasCanonicalAssistantRunEvent = replayEvents.some(isAssistantRunEvent);
|
||||
let hasTerminalRunEvent = replayEvents.some(isTerminalRunEvent);
|
||||
let previousChatProjectionText: string | undefined;
|
||||
const toRunStreamEvent = (event: OpenClawEvent): OpenClawEvent | undefined => {
|
||||
const chatProjection = readChatProjection(event);
|
||||
if (chatProjection?.state === "delta") {
|
||||
if (hasCanonicalAssistantRunEvent) {
|
||||
return undefined;
|
||||
}
|
||||
const runEvent = normalizeChatProjectionEvent(
|
||||
event,
|
||||
chatProjection,
|
||||
previousChatProjectionText,
|
||||
);
|
||||
const text = readChatProjectionText(chatProjection.payload);
|
||||
if (text !== undefined) {
|
||||
previousChatProjectionText = text;
|
||||
}
|
||||
return runEvent;
|
||||
}
|
||||
if (chatProjection?.state === "final") {
|
||||
if (hasTerminalRunEvent) {
|
||||
return undefined;
|
||||
}
|
||||
hasTerminalRunEvent = true;
|
||||
return normalizeChatProjectionEvent(event, chatProjection, previousChatProjectionText);
|
||||
}
|
||||
if (isAssistantRunEvent(event)) {
|
||||
hasCanonicalAssistantRunEvent = true;
|
||||
}
|
||||
if (isTerminalRunEvent(event)) {
|
||||
hasTerminalRunEvent = true;
|
||||
}
|
||||
return event;
|
||||
};
|
||||
const matches = (event: OpenClawEvent) => event.runId === runId;
|
||||
const liveSource = this.normalizedEvents.stream(matches, { replay: true });
|
||||
const live = liveSource[Symbol.asyncIterator]();
|
||||
const seen = new Set<string>();
|
||||
try {
|
||||
for (const event of replayEvents) {
|
||||
if (seen.has(event.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(event.id);
|
||||
const runEvent = toRunStreamEvent(event);
|
||||
if (!runEvent || (filter && !filter(runEvent))) {
|
||||
continue;
|
||||
}
|
||||
yield runEvent;
|
||||
}
|
||||
while (true) {
|
||||
const next = await live.next();
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
if (seen.has(next.value.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(next.value.id);
|
||||
const runEvent = toRunStreamEvent(next.value);
|
||||
if (!runEvent || (filter && !filter(runEvent))) {
|
||||
continue;
|
||||
}
|
||||
yield runEvent;
|
||||
}
|
||||
} finally {
|
||||
await live.return?.();
|
||||
}
|
||||
}
|
||||
|
||||
private startEventPump(): Promise<void> {
|
||||
if (this.eventPumpReady) {
|
||||
return this.eventPumpReady;
|
||||
}
|
||||
let markReady = () => {};
|
||||
let ready = false;
|
||||
this.eventPumpReady = new Promise<void>((resolve) => {
|
||||
markReady = () => {
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
ready = true;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
this.eventPumpPromise = (async () => {
|
||||
let iterator: AsyncIterator<GatewayEvent> | undefined;
|
||||
let pumpError: unknown;
|
||||
let hasPumpError = false;
|
||||
try {
|
||||
iterator = this.transport.events()[Symbol.asyncIterator]();
|
||||
while (true) {
|
||||
const next = iterator.next();
|
||||
await Promise.resolve();
|
||||
markReady();
|
||||
const result = await next;
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
const normalized = normalizeGatewayEvent(result.value);
|
||||
this.recordReplayEvent(normalized);
|
||||
this.normalizedEvents.publish(normalized);
|
||||
}
|
||||
} catch (error) {
|
||||
pumpError = error;
|
||||
hasPumpError = true;
|
||||
} finally {
|
||||
markReady();
|
||||
try {
|
||||
await iterator?.return?.();
|
||||
} catch (error) {
|
||||
if (!hasPumpError) {
|
||||
pumpError = error;
|
||||
hasPumpError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasPumpError) {
|
||||
this.normalizedEvents.close(pumpError);
|
||||
return;
|
||||
}
|
||||
this.normalizedEvents.close();
|
||||
})().catch((error: unknown) => {
|
||||
markReady();
|
||||
this.normalizedEvents.close(error);
|
||||
});
|
||||
return this.eventPumpReady;
|
||||
}
|
||||
|
||||
private recordReplayEvent(event: OpenClawEvent): void {
|
||||
if (!event.runId) {
|
||||
return;
|
||||
}
|
||||
let events = this.replayByRunId.get(event.runId);
|
||||
if (!events) {
|
||||
if (this.replayByRunId.size >= MAX_REPLAY_RUNS) {
|
||||
const oldestRunId = this.replayByRunId.keys().next().value;
|
||||
if (oldestRunId) {
|
||||
this.replayByRunId.delete(oldestRunId);
|
||||
}
|
||||
}
|
||||
events = [];
|
||||
this.replayByRunId.set(event.runId, events);
|
||||
}
|
||||
events.push(event);
|
||||
if (events.length > MAX_REPLAY_EVENTS_PER_RUN) {
|
||||
events.splice(0, events.length - MAX_REPLAY_EVENTS_PER_RUN);
|
||||
}
|
||||
}
|
||||
|
||||
private replaySnapshot(runId: string): OpenClawEvent[] {
|
||||
return [...(this.replayByRunId.get(runId) ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
/** Agent-scoped helper for runs and identity lookups. */
|
||||
export class Agent {
|
||||
constructor(
|
||||
private readonly client: OpenClaw,
|
||||
readonly id: string,
|
||||
) {}
|
||||
|
||||
async run(input: string | Omit<AgentRunParams, "agentId">): Promise<Run> {
|
||||
const params: AgentRunParams =
|
||||
typeof input === "string" ? { input, agentId: this.id } : { ...input, agentId: this.id };
|
||||
return await this.client.runs.create(params);
|
||||
}
|
||||
|
||||
async identity(params?: { sessionKey?: string }): Promise<unknown> {
|
||||
return await this.client.request("agent.identity.get", {
|
||||
agentId: this.id,
|
||||
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Run handle for streaming events, waiting, and cancellation. */
|
||||
export class Run {
|
||||
constructor(
|
||||
private readonly client: OpenClaw,
|
||||
readonly id: string,
|
||||
readonly sessionKey?: string,
|
||||
) {}
|
||||
|
||||
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
|
||||
return this.client.runEvents(this.id, filter);
|
||||
}
|
||||
|
||||
async wait(options?: { timeoutMs?: number }): Promise<RunResult> {
|
||||
const timeoutMs = normalizeTimeoutMs(options?.timeoutMs);
|
||||
const raw = await this.client.request(
|
||||
"agent.wait",
|
||||
{
|
||||
runId: this.id,
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
const record = asRecord(raw);
|
||||
const status = runStatusFromWaitPayload(raw);
|
||||
const error = readOptionalString(record.error)
|
||||
? { message: readOptionalString(record.error) ?? "run failed" }
|
||||
: undefined;
|
||||
return {
|
||||
runId: this.id,
|
||||
status,
|
||||
sessionKey: readOptionalString(record.sessionKey) ?? this.sessionKey,
|
||||
sessionId: readOptionalString(record.sessionId),
|
||||
startedAt: readOptionalTimestamp(record.startedAt),
|
||||
endedAt: readOptionalTimestamp(record.endedAt),
|
||||
...(error ? { error } : {}),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(): Promise<unknown> {
|
||||
return await this.client.request("sessions.abort", {
|
||||
runId: this.id,
|
||||
...(this.sessionKey ? { key: this.sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Session handle for sending messages and session-scoped mutations. */
|
||||
export class Session {
|
||||
constructor(
|
||||
private readonly client: OpenClaw,
|
||||
readonly key: string,
|
||||
readonly info?: unknown,
|
||||
) {}
|
||||
|
||||
async send(input: string | Omit<SessionSendParams, "key">): Promise<Run> {
|
||||
const params: SessionSendParams =
|
||||
typeof input === "string" ? { key: this.key, message: input } : { ...input, key: this.key };
|
||||
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
|
||||
if (timeoutMs !== undefined) {
|
||||
params.timeoutMs = timeoutMs;
|
||||
}
|
||||
const raw = await this.client.request("sessions.send", params, {
|
||||
expectFinal: true,
|
||||
...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}),
|
||||
});
|
||||
const record = asRecord(raw);
|
||||
const runId = readOptionalString(record.runId);
|
||||
if (!runId) {
|
||||
throw new Error("sessions.send did not return a runId");
|
||||
}
|
||||
return new Run(this.client, runId, this.key);
|
||||
}
|
||||
|
||||
async abort(runId?: string): Promise<unknown> {
|
||||
return await this.client.request("sessions.abort", {
|
||||
key: this.key,
|
||||
...(runId ? { runId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async patch(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.patch", { ...params, key: this.key });
|
||||
}
|
||||
|
||||
async compact(params?: { maxLines?: number }): Promise<unknown> {
|
||||
return await this.client.request(
|
||||
"sessions.compact",
|
||||
{ key: this.key, ...params },
|
||||
// The server owns the configurable terminal compaction deadline.
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Agent management namespace. */
|
||||
export class AgentsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("agents.list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Agent> {
|
||||
return new Agent(this.client, id);
|
||||
}
|
||||
|
||||
async create(params: AgentsCreateParams): Promise<unknown> {
|
||||
return await this.client.request("agents.create", params);
|
||||
}
|
||||
|
||||
async update(params: AgentsUpdateParams): Promise<unknown> {
|
||||
return await this.client.request("agents.update", params);
|
||||
}
|
||||
|
||||
async delete(params: AgentsDeleteParams): Promise<unknown> {
|
||||
return await this.client.request("agents.delete", params);
|
||||
}
|
||||
}
|
||||
|
||||
/** Session management namespace. */
|
||||
export class SessionsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async create(params: SessionCreateParams = {}): Promise<Session> {
|
||||
const raw = await this.client.request("sessions.create", params);
|
||||
const record = asRecord(raw);
|
||||
const key =
|
||||
readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key;
|
||||
if (!key) {
|
||||
throw new Error("sessions.create did not return a session key");
|
||||
}
|
||||
return new Session(this.client, key, raw);
|
||||
}
|
||||
|
||||
async get(target: SessionTarget | string): Promise<Session> {
|
||||
const key = typeof target === "string" ? target : target.key;
|
||||
return new Session(this.client, key);
|
||||
}
|
||||
|
||||
async resolve(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.resolve", params);
|
||||
}
|
||||
|
||||
async send(input: SessionSendParams): Promise<Run> {
|
||||
return await new Session(this.client, input.key).send(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run creation and lifecycle namespace. */
|
||||
export class RunsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async create(params: RunCreateParams): Promise<Run> {
|
||||
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
|
||||
const normalizedParams = timeoutMs !== undefined ? { ...params, timeoutMs } : params;
|
||||
const raw = await this.client.request("agent", buildAgentParams(normalizedParams), {
|
||||
expectFinal: false,
|
||||
...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}),
|
||||
});
|
||||
const record = asRecord(raw);
|
||||
const runId = readOptionalString(record.runId);
|
||||
if (!runId) {
|
||||
throw new Error("agent did not return a runId");
|
||||
}
|
||||
return new Run(this.client, runId, readOptionalString(record.sessionKey) ?? params.sessionKey);
|
||||
}
|
||||
|
||||
async get(runId: string): Promise<Run> {
|
||||
return new Run(this.client, runId);
|
||||
}
|
||||
|
||||
events(runId: string): AsyncIterable<OpenClawEvent> {
|
||||
return new Run(this.client, runId).events();
|
||||
}
|
||||
|
||||
async wait(runId: string, options?: { timeoutMs?: number }): Promise<RunResult> {
|
||||
return await new Run(this.client, runId).wait(options);
|
||||
}
|
||||
|
||||
async cancel(runId: string, sessionKey?: string): Promise<unknown> {
|
||||
return await new Run(this.client, runId, sessionKey).cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class RpcNamespace {
|
||||
constructor(
|
||||
protected readonly client: OpenClaw,
|
||||
private readonly prefix: string,
|
||||
) {}
|
||||
|
||||
protected async call<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
return await this.client.request<T>(`${this.prefix}.${method}`, params, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Task query and cancellation namespace. */
|
||||
export class TasksNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "tasks");
|
||||
}
|
||||
|
||||
async list(params?: TasksListParams): Promise<TasksListResult> {
|
||||
return await this.call("list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async get(taskId: string): Promise<TasksGetResult> {
|
||||
return await this.call("get", { taskId });
|
||||
}
|
||||
|
||||
async cancel(taskId: string, options?: { reason?: string }): Promise<TasksCancelResult> {
|
||||
return await this.call("cancel", {
|
||||
taskId,
|
||||
...(options?.reason ? { reason: options.reason } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Model catalog and auth status namespace. */
|
||||
export class ModelsNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "models");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.call("list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async status(params?: unknown): Promise<unknown> {
|
||||
return await this.call("authStatus", params);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool catalog, effective tool, and direct invocation namespace. */
|
||||
export class ToolsNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "tools");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.call("catalog", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async effective(params: ToolsEffectiveParams): Promise<unknown> {
|
||||
return await this.call("effective", requireToolsEffectiveSessionKey(params));
|
||||
}
|
||||
|
||||
async invoke(name: string, params?: ToolInvokeParams): Promise<ToolInvokeResult> {
|
||||
return await this.call("invoke", {
|
||||
name,
|
||||
...(params?.args ? { args: params.args } : {}),
|
||||
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params?.agentId ? { agentId: params.agentId } : {}),
|
||||
...(typeof params?.confirm === "boolean" ? { confirm: params.confirm } : {}),
|
||||
...(params?.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Run/session artifact listing and download namespace. */
|
||||
export class ArtifactsNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "artifacts");
|
||||
}
|
||||
|
||||
async list(params: ArtifactQuery): Promise<ArtifactsListResult> {
|
||||
return await this.call("list", requireArtifactQueryScope("oc.artifacts.list", params));
|
||||
}
|
||||
|
||||
async get(id: string, params: ArtifactQuery): Promise<ArtifactsGetResult> {
|
||||
return await this.call("get", {
|
||||
...requireArtifactQueryScope("oc.artifacts.get", params),
|
||||
artifactId: id,
|
||||
});
|
||||
}
|
||||
|
||||
async download(id: string, params: ArtifactQuery): Promise<ArtifactsDownloadResult> {
|
||||
return await this.call("download", {
|
||||
...requireArtifactQueryScope("oc.artifacts.download", params),
|
||||
artifactId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Approval request listing and response namespace. */
|
||||
export class ApprovalsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.client.request("exec.approval.list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async respond(approvalId: string, params: ApprovalDecisionParams): Promise<unknown> {
|
||||
return await this.client.request("exec.approval.resolve", {
|
||||
id: approvalId,
|
||||
decision: params.decision,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Environment discovery namespace. */
|
||||
export class EnvironmentsNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "environments");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<EnvironmentsListResult> {
|
||||
return await this.call("list", params === undefined ? {} : params);
|
||||
}
|
||||
|
||||
async create(params?: unknown): Promise<unknown> {
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.environments.create");
|
||||
}
|
||||
|
||||
async status(environmentId: string): Promise<EnvironmentSummary> {
|
||||
return await this.call("status", { environmentId });
|
||||
}
|
||||
|
||||
async delete(environmentId: string): Promise<unknown> {
|
||||
void environmentId;
|
||||
return unsupportedGatewayApi("oc.environments.delete");
|
||||
}
|
||||
}
|
||||
144
packages/sdk/src/event-hub.ts
Normal file
144
packages/sdk/src/event-hub.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// OpenClaw SDK module implements event hub behavior.
|
||||
import type { GatewayEvent } from "./types.js";
|
||||
|
||||
// Async event hub with bounded replay for SDK event streams.
|
||||
type Listener<T> = (event: T) => void;
|
||||
|
||||
/** Replay settings for EventHub streams. */
|
||||
export type EventHubOptions = {
|
||||
replayLimit?: number;
|
||||
};
|
||||
|
||||
/** Per-stream options for including replayed events. */
|
||||
export type EventStreamOptions = {
|
||||
replay?: boolean;
|
||||
};
|
||||
|
||||
/** Small publish/subscribe hub used by SDK transports and normalized events. */
|
||||
export class EventHub<T> {
|
||||
private readonly replayLimit: number;
|
||||
private readonly replayEvents: T[] = [];
|
||||
private closed = false;
|
||||
private closeError: unknown;
|
||||
private hasCloseError = false;
|
||||
private readonly listeners = new Set<Listener<T>>();
|
||||
private readonly waiters = new Set<() => void>();
|
||||
|
||||
constructor(options: EventHubOptions = {}) {
|
||||
this.replayLimit = options.replayLimit ?? 0;
|
||||
}
|
||||
|
||||
publish(event: T): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (this.replayLimit > 0) {
|
||||
this.replayEvents.push(event);
|
||||
const overflow = this.replayEvents.length - this.replayLimit;
|
||||
if (overflow > 0) {
|
||||
this.replayEvents.splice(0, overflow);
|
||||
}
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
close(error?: unknown): void {
|
||||
const hasError = arguments.length > 0;
|
||||
if (hasError) {
|
||||
this.closeError = error;
|
||||
this.hasCloseError = true;
|
||||
}
|
||||
this.closed = true;
|
||||
this.replayEvents.length = 0;
|
||||
this.listeners.clear();
|
||||
for (const wake of this.waiters) {
|
||||
wake();
|
||||
}
|
||||
this.waiters.clear();
|
||||
}
|
||||
|
||||
snapshot(filter?: (event: T) => boolean): T[] {
|
||||
return filter ? this.replayEvents.filter(filter) : [...this.replayEvents];
|
||||
}
|
||||
|
||||
stream(filter?: (event: T) => boolean, options: EventStreamOptions = {}): AsyncIterable<T> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: (): AsyncIterator<T> => {
|
||||
const queue: T[] = options.replay ? this.snapshot(filter) : [];
|
||||
let stopped = false;
|
||||
let wake: (() => void) | null = null;
|
||||
const wakePending = () => {
|
||||
const pending = wake;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
wake = null;
|
||||
this.waiters.delete(pending);
|
||||
pending();
|
||||
};
|
||||
const listener = (event: T) => {
|
||||
if (!filter || filter(event)) {
|
||||
queue.push(event);
|
||||
wakePending();
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
this.listeners.delete(listener);
|
||||
wakePending();
|
||||
};
|
||||
|
||||
this.listeners.add(listener);
|
||||
|
||||
return {
|
||||
next: async (): Promise<IteratorResult<T>> => {
|
||||
while (true) {
|
||||
if (stopped) {
|
||||
break;
|
||||
}
|
||||
if (queue.length > 0) {
|
||||
return { done: false, value: queue.shift() as T };
|
||||
}
|
||||
if (this.closed) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const wakeCurrent = () => {
|
||||
if (wake === wakeCurrent) {
|
||||
wake = null;
|
||||
}
|
||||
this.waiters.delete(wakeCurrent);
|
||||
resolve();
|
||||
};
|
||||
wake = wakeCurrent;
|
||||
this.waiters.add(wakeCurrent);
|
||||
});
|
||||
}
|
||||
cleanup();
|
||||
if (this.hasCloseError) {
|
||||
throw this.closeError;
|
||||
}
|
||||
return { done: true, value: undefined as never };
|
||||
},
|
||||
return: async (): Promise<IteratorResult<T>> => {
|
||||
cleanup();
|
||||
return { done: true, value: undefined as never };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayEvent(value: unknown): value is GatewayEvent {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as { event?: unknown }).event === "string"
|
||||
);
|
||||
}
|
||||
738
packages/sdk/src/index.e2e.test.ts
Normal file
738
packages/sdk/src/index.e2e.test.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
// OpenClaw SDK tests cover index behavior.
|
||||
import type { AddressInfo } from "node:net";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { WebSocketServer, type RawData, type WebSocket } from "ws";
|
||||
import { installGatewayTestHooks, startServer } from "../../../src/gateway/test-helpers.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../../../src/infra/agent-events.js";
|
||||
import { GatewayClientTransport, OpenClaw } from "./index.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type FakeGatewayRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
type FakeGateway = {
|
||||
url: string;
|
||||
requests: FakeGatewayRequest[];
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
const servers: WebSocketServer[] = [];
|
||||
|
||||
function expectJsonObject(value: unknown): JsonObject {
|
||||
expect(value && typeof value).toBe("object");
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function sendJson(socket: WebSocket, payload: JsonObject): void {
|
||||
socket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readRawMessage(raw: RawData): string {
|
||||
if (typeof raw === "string") {
|
||||
return raw;
|
||||
}
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
return raw.toString("utf8");
|
||||
}
|
||||
if (raw instanceof ArrayBuffer) {
|
||||
return Buffer.from(raw).toString("utf8");
|
||||
}
|
||||
return Buffer.concat(raw).toString("utf8");
|
||||
}
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createFakeGateway(port = 0): Promise<FakeGateway> {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port });
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.once("listening", resolve);
|
||||
});
|
||||
let seq = 1;
|
||||
const requests: FakeGatewayRequest[] = [];
|
||||
const sockets = new Set<WebSocket>();
|
||||
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
sendJson(socket, {
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
seq: seq++,
|
||||
payload: { nonce: "sdk-e2e-nonce" },
|
||||
});
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
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") {
|
||||
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",
|
||||
"tasks.cancel",
|
||||
"tasks.get",
|
||||
"tasks.list",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"tools.invoke",
|
||||
],
|
||||
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;
|
||||
reply({
|
||||
status: "accepted",
|
||||
runId: "run-sdk-e2e",
|
||||
sessionKey: params?.sessionKey,
|
||||
});
|
||||
setTimeout(() => {
|
||||
sendJson(socket, {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
seq: seq++,
|
||||
payload: {
|
||||
runId: "run-sdk-e2e",
|
||||
sessionKey: params?.sessionKey,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start" },
|
||||
},
|
||||
});
|
||||
sendJson(socket, {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
seq: seq++,
|
||||
payload: {
|
||||
runId: "run-sdk-e2e",
|
||||
sessionKey: params?.sessionKey,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "hello from fake gateway" },
|
||||
},
|
||||
});
|
||||
sendJson(socket, {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
seq: seq++,
|
||||
payload: {
|
||||
runId: "run-sdk-e2e",
|
||||
sessionKey: params?.sessionKey,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
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") {
|
||||
reply({
|
||||
ok: true,
|
||||
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 === "tasks.list") {
|
||||
reply({
|
||||
tasks: [
|
||||
{
|
||||
id: "task-sdk-e2e",
|
||||
status: "running",
|
||||
title: "SDK task",
|
||||
runId: "run-sdk-e2e",
|
||||
sessionKey: "sdk-session",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "tasks.get") {
|
||||
reply({
|
||||
task: {
|
||||
id: (frame.params as { taskId?: string } | undefined)?.taskId ?? "task-sdk-e2e",
|
||||
status: "running",
|
||||
title: "SDK task",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "tasks.cancel") {
|
||||
reply({
|
||||
found: true,
|
||||
cancelled: true,
|
||||
task: {
|
||||
id: (frame.params as { taskId?: string } | undefined)?.taskId ?? "task-sdk-e2e",
|
||||
status: "cancelled",
|
||||
},
|
||||
});
|
||||
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 === "tools.invoke") {
|
||||
reply({ ok: true, toolName: "shell", output: { ok: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "exec.approval.list") {
|
||||
reply({ approvals: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "exec.approval.resolve") {
|
||||
expect(frame.params).toMatchObject({ id: "approval-1", decision: "allow-once" });
|
||||
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<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenClaw SDK websocket e2e", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
for (const client of server.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an agent and streams normalized events 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 {
|
||||
const agent = await oc.agents.get("main");
|
||||
const run = await agent.run({
|
||||
input: "say hello",
|
||||
sessionKey: "main",
|
||||
idempotencyKey: "sdk-e2e",
|
||||
});
|
||||
const seenPromise = (async () => {
|
||||
const seen: string[] = [];
|
||||
|
||||
for await (const event of run.events()) {
|
||||
seen.push(event.type);
|
||||
if (event.type === "run.completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return seen;
|
||||
})();
|
||||
|
||||
const [seen, result] = await Promise.all([
|
||||
withTimeout(seenPromise, 2_000, "timed out waiting for SDK run events"),
|
||||
run.wait({ timeoutMs: 2_000 }),
|
||||
]);
|
||||
|
||||
expect(run.id).toBe("run-sdk-e2e");
|
||||
expect(seen).toEqual(["run.started", "assistant.delta", "run.completed"]);
|
||||
expect(result.runId).toBe("run-sdk-e2e");
|
||||
expect(result.sessionKey).toBe("main");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.startedAt).toBe(123);
|
||||
expect(result.endedAt).toBe(456);
|
||||
const cancelResult = expectJsonObject(await run.cancel());
|
||||
expect(cancelResult.abortedRunId).toBe("run-sdk-e2e");
|
||||
expect(cancelResult.status).toBe("aborted");
|
||||
} finally {
|
||||
await oc.close();
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
const agents = expectJsonObject(await oc.agents.list());
|
||||
expect(agents.agents).toEqual([{ id: "main" }]);
|
||||
const agent = await oc.agents.get("main");
|
||||
const identity = expectJsonObject(await agent.identity({ sessionKey: "sdk-session" }));
|
||||
expect(identity.agentId).toBe("main");
|
||||
expect(identity.sessionKey).toBe("sdk-session");
|
||||
const createAgent = expectJsonObject(
|
||||
await oc.agents.create({ name: "SDK Agent", workspace: "/tmp/sdk-agent" }),
|
||||
);
|
||||
expect(createAgent.method).toBe("agents.create");
|
||||
expect(createAgent.params).toEqual({ name: "SDK Agent", workspace: "/tmp/sdk-agent" });
|
||||
const updateAgent = expectJsonObject(
|
||||
await oc.agents.update({ agentId: "sdk-agent", name: "Renamed SDK Agent" }),
|
||||
);
|
||||
expect(updateAgent.method).toBe("agents.update");
|
||||
expect(updateAgent.params).toEqual({ agentId: "sdk-agent", name: "Renamed SDK Agent" });
|
||||
const deleteAgent = expectJsonObject(await oc.agents.delete({ agentId: "sdk-agent" }));
|
||||
expect(deleteAgent.method).toBe("agents.delete");
|
||||
expect(deleteAgent.params).toEqual({ agentId: "sdk-agent" });
|
||||
|
||||
const sessions = expectJsonObject(await oc.sessions.list());
|
||||
expect(sessions.sessions).toEqual([{ key: "sdk-session" }]);
|
||||
const session = await oc.sessions.create({ key: "sdk-session", agentId: "main" });
|
||||
expect(session.key).toBe("sdk-session");
|
||||
const resolvedSession = expectJsonObject(await oc.sessions.resolve({ key: "sdk-session" }));
|
||||
expect(resolvedSession.key).toBe("sdk-session");
|
||||
const sessionRun = await session.send("continue");
|
||||
expect(sessionRun.id).toBe("run-session-e2e");
|
||||
const abortSession = expectJsonObject(await session.abort(sessionRun.id));
|
||||
expect(abortSession.abortedRunId).toBe("run-session-e2e");
|
||||
const patchSession = expectJsonObject(await session.patch({ label: "Renamed" }));
|
||||
expect(patchSession.method).toBe("sessions.patch");
|
||||
const compactSession = expectJsonObject(await session.compact({ maxLines: 200 }));
|
||||
expect(compactSession.method).toBe("sessions.compact");
|
||||
|
||||
const tasks = await oc.tasks.list({ status: "running" });
|
||||
expect(tasks.tasks).toEqual([
|
||||
{
|
||||
id: "task-sdk-e2e",
|
||||
status: "running",
|
||||
title: "SDK task",
|
||||
runId: "run-sdk-e2e",
|
||||
sessionKey: "sdk-session",
|
||||
},
|
||||
]);
|
||||
const task = await oc.tasks.get("task-sdk-e2e");
|
||||
expect(task.task).toEqual({
|
||||
id: "task-sdk-e2e",
|
||||
status: "running",
|
||||
title: "SDK task",
|
||||
});
|
||||
const cancelledTask = await oc.tasks.cancel("task-sdk-e2e");
|
||||
expect(cancelledTask.cancelled).toBe(true);
|
||||
|
||||
const models = expectJsonObject(await oc.models.list());
|
||||
expect(models.models).toEqual([{ id: "gpt-5.4" }]);
|
||||
const modelStatus = expectJsonObject(await oc.models.status({ probe: false }));
|
||||
expect(modelStatus.providers).toEqual([]);
|
||||
const tools = expectJsonObject(await oc.tools.list());
|
||||
expect(tools.tools).toEqual([{ name: "shell" }]);
|
||||
const effectiveTools = expectJsonObject(
|
||||
await oc.tools.effective({ sessionKey: "sdk-session" }),
|
||||
);
|
||||
expect(effectiveTools.tools).toEqual([{ name: "shell", enabled: true }]);
|
||||
const toolResult = await oc.tools.invoke("shell", {
|
||||
args: { command: "pwd" },
|
||||
sessionKey: "sdk-session",
|
||||
});
|
||||
expect(toolResult.ok).toBe(true);
|
||||
expect(toolResult.toolName).toBe("shell");
|
||||
expect(toolResult.output).toEqual({ ok: true });
|
||||
const approvals = expectJsonObject(await oc.approvals.list());
|
||||
expect(approvals.approvals).toEqual([]);
|
||||
const approvalResult = expectJsonObject(
|
||||
await oc.approvals.respond("approval-1", { decision: "allow-once" }),
|
||||
);
|
||||
expect(approvalResult.ok).toBe(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",
|
||||
"tasks.list",
|
||||
"tasks.get",
|
||||
"tasks.cancel",
|
||||
"models.list",
|
||||
"models.authStatus",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"tools.invoke",
|
||||
"exec.approval.list",
|
||||
"exec.approval.resolve",
|
||||
]);
|
||||
const requestParams = new Map(
|
||||
gateway.requests.map((request) => [request.method, request.params]),
|
||||
);
|
||||
expect(requestParams.get("agents.list")).toEqual({});
|
||||
expect(requestParams.get("sessions.list")).toEqual({});
|
||||
expect(requestParams.get("models.list")).toEqual({});
|
||||
expect(requestParams.get("tools.catalog")).toEqual({});
|
||||
expect(requestParams.get("exec.approval.list")).toEqual({});
|
||||
} 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}`;
|
||||
const transport = new GatewayClientTransport({
|
||||
url,
|
||||
deviceIdentity: null,
|
||||
connectChallengeTimeoutMs: 200,
|
||||
preauthHandshakeTimeoutMs: 200,
|
||||
requestTimeoutMs: 500,
|
||||
});
|
||||
|
||||
const initialConnectError = await transport.connect().catch((error: unknown) => error);
|
||||
expect(initialConnectError).toBeInstanceOf(Error);
|
||||
expect(String(initialConnectError)).toMatch(/ECONNREFUSED/);
|
||||
|
||||
const gateway = await createFakeGateway(port);
|
||||
try {
|
||||
await expect(transport.connect()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await transport.close();
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenClaw SDK real Gateway e2e", () => {
|
||||
installGatewayTestHooks({ scope: "test" });
|
||||
|
||||
it("streams real Gateway agent events", async () => {
|
||||
const token = "sdk-real-gateway-token";
|
||||
const started = await startServer(token, { controlUiEnabled: false });
|
||||
const transport = new GatewayClientTransport({
|
||||
url: `ws://127.0.0.1:${started.port}`,
|
||||
token,
|
||||
deviceIdentity: null,
|
||||
requestTimeoutMs: 2_000,
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
const runId = "sdk-real-gateway-run";
|
||||
|
||||
try {
|
||||
await oc.connect();
|
||||
|
||||
registerAgentRunContext(runId, {
|
||||
sessionKey: "agent:main:dashboard:sdk-real-gateway",
|
||||
verboseLevel: "off",
|
||||
});
|
||||
|
||||
const run = await oc.runs.get(runId);
|
||||
const eventsPromise = (async () => {
|
||||
const seen: string[] = [];
|
||||
const sessionKeys: Array<string | undefined> = [];
|
||||
for await (const event of run.events()) {
|
||||
seen.push(event.type);
|
||||
sessionKeys.push(event.sessionKey);
|
||||
if (event.type === "run.completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { seen, sessionKeys };
|
||||
})();
|
||||
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start", startedAt: 111 },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { delta: "hello from real gateway" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end", endedAt: 222 },
|
||||
});
|
||||
|
||||
const { seen, sessionKeys } = await withTimeout(
|
||||
eventsPromise,
|
||||
2_000,
|
||||
"timed out waiting for real Gateway SDK events",
|
||||
);
|
||||
expect(seen).toEqual(["run.started", "assistant.delta", "run.completed"]);
|
||||
expect(sessionKeys).toEqual([
|
||||
"agent:main:dashboard:sdk-real-gateway",
|
||||
"agent:main:dashboard:sdk-real-gateway",
|
||||
"agent:main:dashboard:sdk-real-gateway",
|
||||
]);
|
||||
} finally {
|
||||
await oc.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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<string, unknown>;
|
||||
for (const key of ["delta", "text", "content"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function expectArrayProperty(value: unknown, property: string): void {
|
||||
expect(value && typeof value).toBe("object");
|
||||
const record = value as Record<string, unknown>;
|
||||
expect(Array.isArray(record[property])).toBe(true);
|
||||
}
|
||||
|
||||
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();
|
||||
expectArrayProperty(await oc.agents.list(), "agents");
|
||||
expectArrayProperty(await oc.models.status({ probe: false }), "providers");
|
||||
|
||||
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 withTimeout(
|
||||
eventsPromise,
|
||||
5_000,
|
||||
"timed out waiting for live SDK run events",
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
1639
packages/sdk/src/index.test.ts
Normal file
1639
packages/sdk/src/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
63
packages/sdk/src/index.ts
Normal file
63
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Public OpenClaw SDK entrypoint. Re-export client namespaces, event helpers,
|
||||
// transport, and stable SDK types from focused modules.
|
||||
export {
|
||||
Agent,
|
||||
AgentsNamespace,
|
||||
ApprovalsNamespace,
|
||||
ArtifactsNamespace,
|
||||
EnvironmentsNamespace,
|
||||
ModelsNamespace,
|
||||
OpenClaw,
|
||||
Run,
|
||||
RunsNamespace,
|
||||
Session,
|
||||
SessionsNamespace,
|
||||
TasksNamespace,
|
||||
ToolsNamespace,
|
||||
type OpenClawOptions,
|
||||
} from "./client.js";
|
||||
export { EventHub, isGatewayEvent } from "./event-hub.js";
|
||||
export { normalizeGatewayEvent } from "./normalize.js";
|
||||
export { GatewayClientTransport, isConnectableTransport } from "./transport.js";
|
||||
export type {
|
||||
AgentsCreateParams,
|
||||
AgentsDeleteParams,
|
||||
AgentsUpdateParams,
|
||||
AgentRunParams,
|
||||
ApprovalDecisionParams,
|
||||
ApprovalMode,
|
||||
ArtifactQuery,
|
||||
ArtifactSummary,
|
||||
ArtifactsDownloadResult,
|
||||
ArtifactsGetResult,
|
||||
ArtifactsListResult,
|
||||
ConnectableOpenClawTransport,
|
||||
EnvironmentSelection,
|
||||
EnvironmentSummary,
|
||||
EnvironmentsListResult,
|
||||
GatewayEvent,
|
||||
GatewayRequestOptions,
|
||||
JsonObject,
|
||||
OpenClawEvent,
|
||||
OpenClawEventType,
|
||||
OpenClawTransport,
|
||||
RunCreateParams,
|
||||
RunResult,
|
||||
RunStatus,
|
||||
RuntimeSelection,
|
||||
SDKError,
|
||||
SDKMessage,
|
||||
SessionCreateParams,
|
||||
SessionSendParams,
|
||||
SessionTarget,
|
||||
TaskStatus,
|
||||
TaskSummary,
|
||||
TasksCancelResult,
|
||||
TasksGetResult,
|
||||
TasksListParams,
|
||||
TasksListResult,
|
||||
ToolsEffectiveParams,
|
||||
ToolInvokeParams,
|
||||
ToolInvokeResult,
|
||||
WorkspaceSelection,
|
||||
} from "./types.js";
|
||||
34
packages/sdk/src/normalize.test.ts
Normal file
34
packages/sdk/src/normalize.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeGatewayEvent } from "./normalize.js";
|
||||
|
||||
// Terminal tool/item events are emitted with phase:"end" plus the real status
|
||||
// (running|completed|failed|blocked), so failed/blocked must not collapse to completed.
|
||||
function agentItemEvent(data: Record<string, unknown>) {
|
||||
return { event: "agent", payload: { runId: "r1", stream: "item", data } };
|
||||
}
|
||||
|
||||
describe("normalizeGatewayEvent terminal tool item status", () => {
|
||||
it("classifies a failed terminal tool item as tool.call.failed", () => {
|
||||
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end", status: "failed" })).type).toBe(
|
||||
"tool.call.failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies a blocked terminal tool item as tool.call.failed", () => {
|
||||
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end", status: "blocked" })).type).toBe(
|
||||
"tool.call.failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("still classifies a completed terminal tool item as tool.call.completed", () => {
|
||||
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end", status: "completed" })).type).toBe(
|
||||
"tool.call.completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("still classifies a phase:end tool item without status as tool.call.completed", () => {
|
||||
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end" })).type).toBe(
|
||||
"tool.call.completed",
|
||||
);
|
||||
});
|
||||
});
|
||||
195
packages/sdk/src/normalize.ts
Normal file
195
packages/sdk/src/normalize.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// OpenClaw SDK helper module supports normalize behavior.
|
||||
import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js";
|
||||
|
||||
// Normalize raw Gateway events into stable SDK event types and common metadata.
|
||||
function asRecord(value: unknown): JsonObject {
|
||||
return typeof value === "object" && value !== null ? (value as JsonObject) : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readLowerString(value: unknown): string | undefined {
|
||||
return readString(value)?.toLowerCase();
|
||||
}
|
||||
|
||||
function hasHardTimeoutMetadata(data: JsonObject, statusAlreadyTimeoutAttributed = false): boolean {
|
||||
const timeoutPhase = readLowerString(data.timeoutPhase);
|
||||
return (
|
||||
(statusAlreadyTimeoutAttributed && data.providerStarted === true) ||
|
||||
timeoutPhase === "preflight" ||
|
||||
timeoutPhase === "provider" ||
|
||||
timeoutPhase === "post_turn"
|
||||
);
|
||||
}
|
||||
|
||||
function isLifecycleCancellation(data: JsonObject): boolean {
|
||||
const status = readLowerString(data.status);
|
||||
const stopReason = readLowerString(data.stopReason);
|
||||
return (
|
||||
status === "aborted" ||
|
||||
status === "cancelled" ||
|
||||
status === "canceled" ||
|
||||
status === "killed" ||
|
||||
stopReason === "aborted" ||
|
||||
stopReason === "cancelled" ||
|
||||
stopReason === "canceled" ||
|
||||
stopReason === "killed" ||
|
||||
stopReason === "auth-revoked" ||
|
||||
stopReason === "restart" ||
|
||||
stopReason === "rpc" ||
|
||||
stopReason === "user" ||
|
||||
(data.aborted === true && stopReason === "stop")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType {
|
||||
const status = readLowerString(data.status);
|
||||
const stopReason = readLowerString(data.stopReason);
|
||||
const statusAlreadyTimeoutAttributed =
|
||||
stopReason !== "restart" &&
|
||||
(status === "timeout" || status === "timed_out" || data.aborted === true);
|
||||
if (hasHardTimeoutMetadata(data, statusAlreadyTimeoutAttributed)) {
|
||||
return "run.timed_out";
|
||||
}
|
||||
if (isLifecycleCancellation(data)) {
|
||||
return "run.cancelled";
|
||||
}
|
||||
if (
|
||||
status === "timeout" ||
|
||||
status === "timed_out" ||
|
||||
stopReason === "timeout" ||
|
||||
stopReason === "timed_out"
|
||||
) {
|
||||
return "run.timed_out";
|
||||
}
|
||||
if (data.aborted === true) {
|
||||
return "run.timed_out";
|
||||
}
|
||||
return "run.completed";
|
||||
}
|
||||
|
||||
function normalizeAgentEventType(payload: JsonObject): OpenClawEventType {
|
||||
const stream = readString(payload.stream);
|
||||
const data = asRecord(payload.data);
|
||||
const phase = readString(data.phase);
|
||||
const status = readString(data.status);
|
||||
|
||||
if (stream === "assistant") {
|
||||
return data.delta === true || typeof data.delta === "string"
|
||||
? "assistant.delta"
|
||||
: "assistant.message";
|
||||
}
|
||||
if (stream === "thinking" || stream === "plan") {
|
||||
return "thinking.delta";
|
||||
}
|
||||
if (stream === "lifecycle") {
|
||||
if (phase === "start") {
|
||||
return "run.started";
|
||||
}
|
||||
if (phase === "end") {
|
||||
return normalizeLifecycleEndEventType(data);
|
||||
}
|
||||
if (phase === "error") {
|
||||
if (hasHardTimeoutMetadata(data, false)) {
|
||||
return "run.timed_out";
|
||||
}
|
||||
if (isLifecycleCancellation(data)) {
|
||||
return "run.cancelled";
|
||||
}
|
||||
return "run.failed";
|
||||
}
|
||||
}
|
||||
if (stream === "tool" || stream === "item" || stream === "command_output") {
|
||||
if (phase === "start" || status === "running") {
|
||||
return "tool.call.started";
|
||||
}
|
||||
if (phase === "delta" || phase === "update") {
|
||||
return "tool.call.delta";
|
||||
}
|
||||
// Terminal tool/item events carry phase:"end" together with the real status, so a failed or
|
||||
// blocked tool must be classified before the end/completed branch — otherwise phase:"end" wins
|
||||
// and failures are reported as tool.call.completed.
|
||||
if (status === "failed" || status === "blocked") {
|
||||
return "tool.call.failed";
|
||||
}
|
||||
if (phase === "end" || status === "completed") {
|
||||
return "tool.call.completed";
|
||||
}
|
||||
return "tool.call.delta";
|
||||
}
|
||||
if (stream === "approval") {
|
||||
return phase === "resolved" ? "approval.resolved" : "approval.requested";
|
||||
}
|
||||
if (stream === "patch") {
|
||||
return "artifact.updated";
|
||||
}
|
||||
if (stream === "error") {
|
||||
return "run.failed";
|
||||
}
|
||||
return "raw";
|
||||
}
|
||||
|
||||
function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType {
|
||||
const payload = asRecord(event.payload);
|
||||
switch (event.event) {
|
||||
case "agent":
|
||||
return normalizeAgentEventType(payload);
|
||||
case "sessions.changed": {
|
||||
const reason = readString(payload.reason);
|
||||
if (reason === "create") {
|
||||
return "session.created";
|
||||
}
|
||||
if (reason === "compact") {
|
||||
return "session.compacted";
|
||||
}
|
||||
return "session.updated";
|
||||
}
|
||||
case "session.message":
|
||||
return "assistant.message";
|
||||
case "session.tool":
|
||||
return "tool.call.delta";
|
||||
case "exec.approval.requested":
|
||||
case "plugin.approval.requested":
|
||||
return "approval.requested";
|
||||
case "exec.approval.resolved":
|
||||
case "plugin.approval.resolved":
|
||||
return "approval.resolved";
|
||||
case "task.updated":
|
||||
case "tasks.changed":
|
||||
return "task.updated";
|
||||
default:
|
||||
return "raw";
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a raw Gateway event into the public SDK event shape. */
|
||||
export function normalizeGatewayEvent(event: GatewayEvent): OpenClawEvent {
|
||||
const payload = asRecord(event.payload);
|
||||
const runId = readString(payload.runId);
|
||||
const sessionId = readString(payload.sessionId);
|
||||
const sessionKey = readString(payload.sessionKey);
|
||||
const taskId = readString(payload.taskId);
|
||||
const agentId = readString(payload.agentId);
|
||||
const ts = readNumber(payload.ts) ?? Date.now();
|
||||
const idParts = [event.seq ?? "local", event.event, runId, sessionKey, ts].filter(Boolean);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
id: idParts.join(":"),
|
||||
ts,
|
||||
type: normalizeNamedEventType(event),
|
||||
...(runId ? { runId } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(taskId ? { taskId } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
data: payload.data ?? payload,
|
||||
raw: event,
|
||||
};
|
||||
}
|
||||
406
packages/sdk/src/package.e2e.test.ts
Normal file
406
packages/sdk/src/package.e2e.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// OpenClaw SDK tests cover package behavior.
|
||||
import { spawn, spawnSync, type SpawnOptionsWithoutStdio } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveNpmRunner } from "../../../scripts/npm-runner.mjs";
|
||||
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mjs";
|
||||
import { getWindowsSystem32ExePath } from "../../../src/infra/windows-install-roots.js";
|
||||
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
|
||||
|
||||
type CommandResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 120_000;
|
||||
const tempDirs: string[] = [];
|
||||
const WORKSPACE_PACKAGE_NAMES = [
|
||||
"@openclaw/gateway-protocol",
|
||||
"@openclaw/gateway-client",
|
||||
"@openclaw/sdk",
|
||||
] as const;
|
||||
|
||||
type PackageManifest = {
|
||||
name: string;
|
||||
version: string;
|
||||
dependencies?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PackedPackage = {
|
||||
manifest: PackageManifest;
|
||||
tarball: string;
|
||||
};
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; timeoutMs?: number } & Pick<
|
||||
SpawnOptionsWithoutStdio,
|
||||
"env" | "shell" | "windowsVerbatimArguments"
|
||||
>,
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: options.env ?? createCommandEnv(),
|
||||
shell: options.shell,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsVerbatimArguments: options.windowsVerbatimArguments,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
signalCommandProcess(child, "SIGKILL");
|
||||
reject(
|
||||
new Error(
|
||||
`command timed out after ${options.timeoutMs ?? COMMAND_TIMEOUT_MS}ms: ${[
|
||||
command,
|
||||
...args,
|
||||
].join(" ")}`,
|
||||
),
|
||||
);
|
||||
}, options.timeoutMs ?? COMMAND_TIMEOUT_MS);
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
|
||||
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
const result = { stdout: stdout.join(""), stderr: stderr.join("") };
|
||||
if (code === 0) {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`command failed (${String(code ?? signal)}): ${[command, ...args].join(" ")}\n` +
|
||||
`--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function signalCommandProcess(
|
||||
child: ReturnType<typeof spawn>,
|
||||
signal: NodeJS.Signals,
|
||||
runTaskkill: typeof spawnSync = spawnSync,
|
||||
): void {
|
||||
if (process.platform === "win32") {
|
||||
if (typeof child.pid === "number") {
|
||||
const args = ["/PID", String(child.pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const taskkillPath = getWindowsSystem32ExePath("taskkill.exe");
|
||||
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
|
||||
if (!result.error && result.status === 0) {
|
||||
return;
|
||||
}
|
||||
if (signal !== "SIGKILL") {
|
||||
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (!forceResult.error && forceResult.status === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function createCommandEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
CI: process.env.CI ?? "true",
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
|
||||
};
|
||||
}
|
||||
|
||||
function runPnpmCommand(
|
||||
args: string[],
|
||||
options: { cwd: string; timeoutMs?: number },
|
||||
): Promise<CommandResult> {
|
||||
const spec = createPnpmRunnerSpawnSpec({
|
||||
cwd: options.cwd,
|
||||
env: createCommandEnv(),
|
||||
pnpmArgs: args,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const cwd = typeof spec.options.cwd === "string" ? spec.options.cwd : options.cwd;
|
||||
return runCommand(spec.command, spec.args, {
|
||||
cwd,
|
||||
env: spec.options.env,
|
||||
shell: spec.options.shell,
|
||||
timeoutMs: options.timeoutMs,
|
||||
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
function runNpmCommand(
|
||||
args: string[],
|
||||
options: { cwd: string; timeoutMs?: number },
|
||||
): Promise<CommandResult> {
|
||||
const env = createCommandEnv();
|
||||
const runner = resolveNpmRunner({
|
||||
env,
|
||||
npmArgs: args,
|
||||
});
|
||||
return runCommand(runner.command, runner.args, {
|
||||
cwd: options.cwd,
|
||||
env: runner.env ?? env,
|
||||
shell: runner.shell,
|
||||
timeoutMs: options.timeoutMs,
|
||||
windowsVerbatimArguments: runner.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkspaceDependencies(
|
||||
dependencies: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!dependencies) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
normalized[name] =
|
||||
name.startsWith("@openclaw/") && spec === "workspace:*" ? "0.0.0-private" : spec;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readPackageManifest(packageRoot: string): Promise<PackageManifest> {
|
||||
const packageJson = await fs.readFile(path.join(packageRoot, "package.json"), "utf8");
|
||||
const manifest = JSON.parse(packageJson) as PackageManifest;
|
||||
return {
|
||||
...manifest,
|
||||
dependencies: normalizeWorkspaceDependencies(manifest.dependencies),
|
||||
};
|
||||
}
|
||||
|
||||
function tarballFileName(manifest: PackageManifest): string {
|
||||
return `${manifest.name.replace(/^@/, "").replace("/", "-")}-${manifest.version}.tgz`;
|
||||
}
|
||||
|
||||
async function createPackStagingRoot(
|
||||
packageRoot: string,
|
||||
destinationRoot: string,
|
||||
): Promise<string> {
|
||||
const manifest = await readPackageManifest(packageRoot);
|
||||
const packageSlug = manifest.name.replace(/^@/, "").replace("/", "-");
|
||||
const stagingRoot = path.join(destinationRoot, `pack-${packageSlug}`);
|
||||
await fs.mkdir(stagingRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(stagingRoot, "package.json"), JSON.stringify(manifest, null, 2));
|
||||
const files: string[] = Array.isArray(manifest.files) ? (manifest.files as string[]) : [];
|
||||
for (const entry of files) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
await fs.cp(path.join(packageRoot, entry), path.join(stagingRoot, entry), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
return stagingRoot;
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
async function startOpenClawRegistry(packages: PackedPackage[]): Promise<{
|
||||
registryUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const byName = new Map(packages.map((pkg) => [pkg.manifest.name, pkg]));
|
||||
const byTarball = new Map(packages.map((pkg) => [path.basename(pkg.tarball), pkg]));
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const host = req.headers.host ?? "127.0.0.1";
|
||||
const url = new URL(req.url ?? "/", `http://${host}`);
|
||||
const decodedPath = decodeURIComponent(url.pathname);
|
||||
|
||||
if (decodedPath.startsWith("/tarballs/")) {
|
||||
const fileName = decodedPath.slice("/tarballs/".length);
|
||||
const pkg = byTarball.get(fileName);
|
||||
if (!pkg) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/octet-stream" });
|
||||
createReadStream(pkg.tarball).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageName = decodedPath.slice(1);
|
||||
const pkg = byName.get(packageName);
|
||||
if (!pkg) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = `http://${host}`;
|
||||
const body = {
|
||||
name: pkg.manifest.name,
|
||||
"dist-tags": { latest: pkg.manifest.version },
|
||||
versions: {
|
||||
[pkg.manifest.version]: {
|
||||
...pkg.manifest,
|
||||
dist: {
|
||||
tarball: `${baseUrl}/tarballs/${encodeURIComponent(path.basename(pkg.tarball))}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error("registry server did not bind to a TCP port");
|
||||
}
|
||||
return {
|
||||
registryUrl: `http://127.0.0.1:${address.port}/`,
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenClaw SDK package e2e", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("force-kills Windows package command process trees when graceful taskkill fails", () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
try {
|
||||
const killMock = vi.fn();
|
||||
const child = {
|
||||
pid: 12345,
|
||||
kill: killMock,
|
||||
} as unknown as ReturnType<typeof spawn>;
|
||||
const runTaskkill = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1 })
|
||||
.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
signalCommandProcess(child, "SIGTERM", runTaskkill);
|
||||
|
||||
const taskkillPath = getWindowsSystem32ExePath("taskkill.exe");
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(killMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("packs and imports from an external temp consumer", async () => {
|
||||
const repoRoot = process.cwd();
|
||||
const packageRoots = [
|
||||
path.join(repoRoot, "packages", "gateway-protocol"),
|
||||
path.join(repoRoot, "packages", "gateway-client"),
|
||||
path.join(repoRoot, "packages", "sdk"),
|
||||
];
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sdk-consumer-"));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
for (const packageName of WORKSPACE_PACKAGE_NAMES) {
|
||||
await runPnpmCommand(["--filter", packageName, "build"], {
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
}
|
||||
for (const packageRoot of packageRoots) {
|
||||
const stagingRoot = await createPackStagingRoot(packageRoot, tempDir);
|
||||
await runNpmCommand(["pack", "--ignore-scripts", "--pack-destination", tempDir], {
|
||||
cwd: stagingRoot,
|
||||
});
|
||||
}
|
||||
|
||||
const packedPackages: PackedPackage[] = [];
|
||||
for (const packageRoot of packageRoots) {
|
||||
const manifest = await readPackageManifest(packageRoot);
|
||||
const tarball = path.join(tempDir, tarballFileName(manifest));
|
||||
await fs.stat(tarball);
|
||||
packedPackages.push({ manifest, tarball });
|
||||
}
|
||||
const sdkTarball =
|
||||
packedPackages.find((pkg) => pkg.manifest.name === "@openclaw/sdk")?.tarball ?? "";
|
||||
expect(sdkTarball).not.toBe("");
|
||||
const registry = await startOpenClawRegistry(packedPackages);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ private: true, type: "module" }),
|
||||
);
|
||||
await fs.writeFile(path.join(tempDir, ".npmrc"), `@openclaw:registry=${registry.registryUrl}`);
|
||||
try {
|
||||
await runNpmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
} finally {
|
||||
await registry.close();
|
||||
}
|
||||
|
||||
const importScript = `
|
||||
import { GatewayClientTransport, OpenClaw, normalizeGatewayEvent } from "@openclaw/sdk";
|
||||
if (typeof GatewayClientTransport !== "function") throw new Error("missing transport export");
|
||||
if (typeof OpenClaw !== "function") throw new Error("missing client export");
|
||||
const event = normalizeGatewayEvent({
|
||||
event: "agent",
|
||||
payload: { runId: "pack-smoke", stream: "lifecycle", data: { phase: "start" } }
|
||||
});
|
||||
if (event.type !== "run.started") throw new Error("unexpected event normalization");
|
||||
`;
|
||||
await runCommand(process.execPath, createNodeEvalArgs(importScript, { evalFlag: "-e" }), {
|
||||
cwd: tempDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
98
packages/sdk/src/transport.test.ts
Normal file
98
packages/sdk/src/transport.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// OpenClaw SDK tests cover transport behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayClientTransport } from "./transport.js";
|
||||
|
||||
type MockGatewayClientInstance = {
|
||||
opts: {
|
||||
onConnectError?: (error: Error) => void;
|
||||
onHelloOk?: (hello: unknown) => void;
|
||||
};
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stopAndWait: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const gatewayClientMocks = vi.hoisted(() => ({
|
||||
instances: [] as MockGatewayClientInstance[],
|
||||
}));
|
||||
|
||||
vi.mock("@openclaw/gateway-client", () => ({
|
||||
GatewayClient: class {
|
||||
readonly opts: MockGatewayClientInstance["opts"];
|
||||
readonly request = vi.fn();
|
||||
readonly start = vi.fn();
|
||||
readonly stopAndWait = vi.fn(async () => {});
|
||||
|
||||
constructor(opts: MockGatewayClientInstance["opts"]) {
|
||||
this.opts = opts;
|
||||
gatewayClientMocks.instances.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GatewayClientTransport", () => {
|
||||
beforeEach(() => {
|
||||
gatewayClientMocks.instances.length = 0;
|
||||
});
|
||||
|
||||
it("rejects a pending connect when the transport closes before hello-ok", async () => {
|
||||
const transport = new GatewayClientTransport();
|
||||
|
||||
const connect = transport.connect();
|
||||
const connectExpectation = expect(connect).rejects.toThrow(
|
||||
"gateway transport closed before connect completed",
|
||||
);
|
||||
const client = gatewayClientMocks.instances[0];
|
||||
expect(client?.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await transport.close();
|
||||
|
||||
await connectExpectation;
|
||||
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects reconnect attempts after close", async () => {
|
||||
const transport = new GatewayClientTransport();
|
||||
|
||||
await transport.close();
|
||||
|
||||
await expect(transport.connect()).rejects.toThrow("gateway transport is closed");
|
||||
expect(gatewayClientMocks.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves connect when a hello observer throws", async () => {
|
||||
const onHelloOk = vi.fn(() => {
|
||||
throw new Error("hello observer failed");
|
||||
});
|
||||
const transport = new GatewayClientTransport({ onHelloOk });
|
||||
|
||||
const connect = transport.connect();
|
||||
const client = gatewayClientMocks.instances[0];
|
||||
|
||||
expect(() => client?.opts.onHelloOk?.({ sessionId: "session-1" })).toThrow(
|
||||
"hello observer failed",
|
||||
);
|
||||
|
||||
await expect(connect).resolves.toBeUndefined();
|
||||
expect(onHelloOk).toHaveBeenCalledWith({ sessionId: "session-1" });
|
||||
});
|
||||
|
||||
it("rejects connect when a connect-error observer throws", async () => {
|
||||
const onConnectError = vi.fn(() => {
|
||||
throw new Error("connect observer failed");
|
||||
});
|
||||
const transport = new GatewayClientTransport({ onConnectError });
|
||||
|
||||
const connect = transport.connect();
|
||||
const connectExpectation = expect(connect).rejects.toThrow("gateway rejected");
|
||||
const client = gatewayClientMocks.instances[0];
|
||||
|
||||
expect(() => client?.opts.onConnectError?.(new Error("gateway rejected"))).toThrow(
|
||||
"connect observer failed",
|
||||
);
|
||||
|
||||
await connectExpectation;
|
||||
expect(onConnectError).toHaveBeenCalledOnce();
|
||||
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
181
packages/sdk/src/transport.ts
Normal file
181
packages/sdk/src/transport.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// OpenClaw SDK module implements transport behavior.
|
||||
import { GatewayClient } from "@openclaw/gateway-client";
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import type {
|
||||
ConnectableOpenClawTransport,
|
||||
GatewayEvent,
|
||||
GatewayRequestOptions,
|
||||
OpenClawTransport,
|
||||
} from "./types.js";
|
||||
|
||||
// Gateway transport adapter that converts the lower-level GatewayClient into the
|
||||
// SDK transport interface and replays raw events for late subscribers.
|
||||
type GatewayClientLike = {
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T>;
|
||||
stopAndWait(): Promise<void>;
|
||||
};
|
||||
|
||||
const RAW_EVENT_REPLAY_LIMIT = 1000;
|
||||
|
||||
/** Options passed through to the Gateway websocket client. */
|
||||
export type GatewayClientTransportOptions = {
|
||||
url?: string;
|
||||
connectChallengeTimeoutMs?: number;
|
||||
connectDelayMs?: number;
|
||||
preauthHandshakeTimeoutMs?: number;
|
||||
tickWatchMinIntervalMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
token?: string;
|
||||
bootstrapToken?: string;
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
instanceId?: string;
|
||||
clientName?: string;
|
||||
clientDisplayName?: string;
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
mode?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
pathEnv?: string;
|
||||
deviceIdentity?: unknown;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
tlsFingerprint?: string;
|
||||
onEvent?: (evt: GatewayEvent) => void;
|
||||
onHelloOk?: (hello: unknown) => void;
|
||||
onConnectError?: (err: Error) => void;
|
||||
onReconnectPaused?: (info: unknown) => void;
|
||||
onClose?: (code: number, reason: string) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
};
|
||||
|
||||
function toGatewayEvent(event: unknown): GatewayEvent {
|
||||
const record =
|
||||
typeof event === "object" && event !== null ? (event as Record<string, unknown>) : {};
|
||||
const eventName = typeof record.event === "string" ? record.event : "unknown";
|
||||
return {
|
||||
event: eventName,
|
||||
payload: record.payload,
|
||||
...(typeof record.seq === "number" ? { seq: record.seq } : {}),
|
||||
...(record.stateVersion ? { stateVersion: record.stateVersion } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Connectable SDK transport backed by @openclaw/gateway-client. */
|
||||
export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
private readonly eventsHub = new EventHub<GatewayEvent>({
|
||||
replayLimit: RAW_EVENT_REPLAY_LIMIT,
|
||||
});
|
||||
private readonly options: GatewayClientTransportOptions;
|
||||
private client: GatewayClientLike | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private rejectPendingConnect: ((error: Error) => void) | null = null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: GatewayClientTransportOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("gateway transport is closed"));
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
this.connectPromise = new Promise<void>((resolve, reject) => {
|
||||
this.rejectPendingConnect = reject;
|
||||
const client = new GatewayClient({
|
||||
...this.options,
|
||||
onEvent: (event: unknown) => {
|
||||
const normalized = toGatewayEvent(event);
|
||||
this.eventsHub.publish(normalized);
|
||||
this.options.onEvent?.(normalized);
|
||||
},
|
||||
onHelloOk: (_hello: unknown) => {
|
||||
try {
|
||||
this.options.onHelloOk?.(_hello);
|
||||
} finally {
|
||||
this.rejectPendingConnect = null;
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
onConnectError: (error: Error) => {
|
||||
try {
|
||||
this.options.onConnectError?.(error);
|
||||
} finally {
|
||||
if (this.client === client) {
|
||||
this.client = null;
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
void client.stopAndWait().catch(() => {});
|
||||
this.rejectPendingConnect = null;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
onReconnectPaused: this.options.onReconnectPaused,
|
||||
onClose: this.options.onClose,
|
||||
onGap: this.options.onGap,
|
||||
} as never);
|
||||
|
||||
this.client = client;
|
||||
client.start();
|
||||
});
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("gateway transport is not connected");
|
||||
}
|
||||
return await this.client.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
|
||||
return this.eventsHub.stream(filter, { replay: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closePromise) {
|
||||
return await this.closePromise;
|
||||
}
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.eventsHub.close();
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
const rejectPendingConnect = this.rejectPendingConnect;
|
||||
this.rejectPendingConnect = null;
|
||||
rejectPendingConnect?.(new Error("gateway transport closed before connect completed"));
|
||||
this.connectPromise = null;
|
||||
this.closePromise = client?.stopAndWait() ?? Promise.resolve();
|
||||
await this.closePromise;
|
||||
this.closePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow an SDK transport to one that supports explicit connect. */
|
||||
export function isConnectableTransport(
|
||||
transport: OpenClawTransport,
|
||||
): transport is ConnectableOpenClawTransport {
|
||||
return typeof (transport as { connect?: unknown }).connect === "function";
|
||||
}
|
||||
357
packages/sdk/src/types.ts
Normal file
357
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// Public SDK data contracts for Gateway transport, runs, sessions, tools,
|
||||
// artifacts, tasks, environments, and normalized event streams.
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
/** Per-request options accepted by SDK transports. */
|
||||
export type GatewayRequestOptions = {
|
||||
expectFinal?: boolean;
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
/** Raw event payload emitted by the Gateway transport. */
|
||||
export type GatewayEvent = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
stateVersion?: unknown;
|
||||
};
|
||||
|
||||
/** Minimal transport interface consumed by the OpenClaw SDK client. */
|
||||
export type OpenClawTransport = {
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T>;
|
||||
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent>;
|
||||
close?(): Promise<void> | void;
|
||||
};
|
||||
|
||||
/** Transport variant that requires an explicit connection step. */
|
||||
export type ConnectableOpenClawTransport = OpenClawTransport & {
|
||||
connect(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Desired runtime/harness selection for future per-run execution routing. */
|
||||
export type RuntimeSelection =
|
||||
| "auto"
|
||||
| { type: "embedded"; id: "openclaw" | "codex" | (string & {}) }
|
||||
| { type: "cli"; id: "claude-cli" | (string & {}) }
|
||||
| { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | (string & {}) }
|
||||
| { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | (string & {}) };
|
||||
|
||||
/** Desired execution environment selection for future per-run routing. */
|
||||
export type EnvironmentSelection =
|
||||
| { type: "local"; cwd?: string }
|
||||
| { type: "gateway"; url?: string; cwd?: string }
|
||||
| { type: "node"; nodeId: string; cwd?: string }
|
||||
| { type: "managed"; provider: string; repo?: string; ref?: string }
|
||||
| { type: "ephemeral"; provider: string; repo?: string; ref?: string };
|
||||
|
||||
export type EnvironmentSummary = {
|
||||
id: string;
|
||||
type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {});
|
||||
label?: string;
|
||||
status: "available" | "unavailable" | "starting" | "stopping" | "error";
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
export type EnvironmentsListResult = {
|
||||
environments: EnvironmentSummary[];
|
||||
};
|
||||
|
||||
export type WorkspaceSelection = {
|
||||
cwd?: string;
|
||||
repo?: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export type ApprovalMode = "ask" | "never" | "auto" | "trusted";
|
||||
|
||||
export type ApprovalDecisionParams = {
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
};
|
||||
|
||||
/** Terminal and non-terminal status values returned by Run.wait. */
|
||||
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
|
||||
|
||||
export type RunTimestamp = string | number;
|
||||
|
||||
export type SDKMessage = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
name?: string;
|
||||
toolCallId?: string;
|
||||
};
|
||||
|
||||
/** Metadata for an artifact attached to a run, task, or session. */
|
||||
export type ArtifactSummary = {
|
||||
id: string;
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
type:
|
||||
| "file"
|
||||
| "patch"
|
||||
| "diff"
|
||||
| "log"
|
||||
| "media"
|
||||
| "screenshot"
|
||||
| "trajectory"
|
||||
| "pull_request"
|
||||
| "workspace"
|
||||
| (string & {});
|
||||
title?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
messageSeq?: number;
|
||||
source?: string;
|
||||
download?: {
|
||||
mode: "bytes" | "url" | "unsupported" | (string & {});
|
||||
};
|
||||
createdAt?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type ArtifactQuery =
|
||||
| { sessionKey: string; runId?: string; taskId?: string; agentId?: string }
|
||||
| { runId: string; sessionKey?: string; taskId?: string; agentId?: string }
|
||||
| { taskId: string; sessionKey?: string; runId?: string; agentId?: string };
|
||||
|
||||
export type ArtifactsListResult = {
|
||||
artifacts: ArtifactSummary[];
|
||||
};
|
||||
|
||||
export type ArtifactsGetResult = {
|
||||
artifact: ArtifactSummary;
|
||||
};
|
||||
|
||||
export type ArtifactsDownloadResult = {
|
||||
artifact: ArtifactSummary;
|
||||
encoding?: "base64";
|
||||
data?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type TaskStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "timed_out";
|
||||
|
||||
/** Gateway task summary returned by task list/get calls. */
|
||||
export type TaskSummary = {
|
||||
id: string;
|
||||
taskId?: string;
|
||||
kind?: string;
|
||||
runtime?: string;
|
||||
status: TaskStatus;
|
||||
title?: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
childSessionKey?: string;
|
||||
ownerKey?: string;
|
||||
runId?: string;
|
||||
flowId?: string;
|
||||
parentTaskId?: string;
|
||||
sourceId?: string;
|
||||
createdAt?: RunTimestamp;
|
||||
updatedAt?: RunTimestamp;
|
||||
startedAt?: RunTimestamp;
|
||||
endedAt?: RunTimestamp;
|
||||
progressSummary?: string;
|
||||
terminalSummary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type TasksListParams = {
|
||||
status?: TaskStatus | TaskStatus[];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
export type TasksListResult = {
|
||||
tasks: TaskSummary[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
export type TasksGetResult = {
|
||||
task: TaskSummary;
|
||||
};
|
||||
|
||||
export type TasksCancelResult = {
|
||||
found: boolean;
|
||||
cancelled: boolean;
|
||||
reason?: string;
|
||||
task?: TaskSummary;
|
||||
};
|
||||
|
||||
export type SDKError = {
|
||||
code?: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
/** Parameters for direct tool invocation through the SDK. */
|
||||
export type ToolsEffectiveParams = {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
export type ToolInvokeParams = {
|
||||
args?: JsonObject;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
confirm?: boolean;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export type ToolInvokeResult = {
|
||||
ok: boolean;
|
||||
toolName: string;
|
||||
output?: unknown;
|
||||
requiresApproval?: boolean;
|
||||
approvalId?: string;
|
||||
source?: string;
|
||||
error?: SDKError;
|
||||
};
|
||||
|
||||
/** Normalized result returned by Run.wait. */
|
||||
export type RunResult = {
|
||||
runId: string;
|
||||
status: RunStatus;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
taskId?: string;
|
||||
startedAt?: RunTimestamp;
|
||||
endedAt?: RunTimestamp;
|
||||
output?: {
|
||||
text?: string;
|
||||
messages?: SDKMessage[];
|
||||
};
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
artifacts?: ArtifactSummary[];
|
||||
error?: SDKError;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
/** Stable SDK event type taxonomy derived from raw Gateway events. */
|
||||
export type OpenClawEventType =
|
||||
| "run.created"
|
||||
| "run.queued"
|
||||
| "run.started"
|
||||
| "run.completed"
|
||||
| "run.failed"
|
||||
| "run.cancelled"
|
||||
| "run.timed_out"
|
||||
| "assistant.delta"
|
||||
| "assistant.message"
|
||||
| "thinking.delta"
|
||||
| "tool.call.started"
|
||||
| "tool.call.delta"
|
||||
| "tool.call.completed"
|
||||
| "tool.call.failed"
|
||||
| "approval.requested"
|
||||
| "approval.resolved"
|
||||
| "question.requested"
|
||||
| "question.answered"
|
||||
| "artifact.created"
|
||||
| "artifact.updated"
|
||||
| "session.created"
|
||||
| "session.updated"
|
||||
| "session.compacted"
|
||||
| "task.updated"
|
||||
| "git.branch"
|
||||
| "git.diff"
|
||||
| "git.pr"
|
||||
| "raw";
|
||||
|
||||
/** Normalized SDK event with common run/session/task metadata. */
|
||||
export type OpenClawEvent<TData = unknown> = {
|
||||
version: 1;
|
||||
id: string;
|
||||
ts: number;
|
||||
type: OpenClawEventType;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
data: TData;
|
||||
raw?: GatewayEvent;
|
||||
};
|
||||
|
||||
/** Parameters for creating an agent run. */
|
||||
export type AgentRunParams = {
|
||||
input: string;
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
deliver?: boolean;
|
||||
attachments?: unknown[];
|
||||
timeoutMs?: number;
|
||||
label?: string;
|
||||
runtime?: RuntimeSelection;
|
||||
environment?: EnvironmentSelection;
|
||||
workspace?: WorkspaceSelection;
|
||||
approvals?: ApprovalMode;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
/** Parameters for creating a session. */
|
||||
export type SessionCreateParams = {
|
||||
key?: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
model?: string;
|
||||
parentSessionKey?: string;
|
||||
task?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/** Parameters for sending a message to an existing session. */
|
||||
export type SessionSendParams = {
|
||||
key: string;
|
||||
message: string;
|
||||
thinking?: string;
|
||||
attachments?: unknown[];
|
||||
timeoutMs?: number;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export type SessionTarget = {
|
||||
key: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type RunCreateParams = AgentRunParams;
|
||||
|
||||
export type AgentsCreateParams = {
|
||||
name: string;
|
||||
workspace: string;
|
||||
model?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type AgentsUpdateParams = {
|
||||
agentId: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
model?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type AgentsDeleteParams = {
|
||||
agentId: string;
|
||||
deleteFiles?: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user