@newbase-clawchat/openclaw-clawchat 2026.4.22 → 2026.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newbase-clawchat/openclaw-clawchat",
3
- "version": "2026.4.22",
3
+ "version": "2026.4.23",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "files": [
6
6
  "index.ts",
@@ -12,8 +12,7 @@
12
12
  "scripts": {
13
13
  "typecheck": "tsc --noEmit",
14
14
  "prepublishOnly": "npm run typecheck",
15
- "release": "npm run prepublishOnly && npm publish",
16
- "test-ui": "node ./tools/standalone-webchat-server.mjs"
15
+ "release": "npm run prepublishOnly && npm publish"
17
16
  },
18
17
  "dependencies": {
19
18
  "@newbase-clawchat/sdk": "^0.1.0",
@@ -46,11 +45,10 @@
46
45
  "docsPath": "/channels/openclaw-clawchat",
47
46
  "docsLabel": "openclaw-clawchat",
48
47
  "blurb": "OpenClaw ClawChat channel plugin",
49
- "order": 110
48
+ "order": 70
50
49
  },
51
50
  "install": {
52
51
  "npmSpec": "@newbase-clawchat/openclaw-clawchat",
53
- "localPath": "extensions/openclaw-clawchat",
54
52
  "defaultChoice": "npm",
55
53
  "minHostVersion": ">=2026.3.23"
56
54
  }
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  const getClientMock = vi.hoisted(() => vi.fn());
4
4
  const getRuntimeMock = vi.hoisted(() => vi.fn());
5
+ const waitForClientMock = vi.hoisted(() => vi.fn());
5
6
  const uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
6
7
  const createApiClientMock = vi.hoisted(() => vi.fn());
7
8
  const sendTextMock = vi.hoisted(() => vi.fn());
@@ -10,6 +11,7 @@ const sendMediaMock = vi.hoisted(() => vi.fn());
10
11
  vi.mock("./runtime.ts", () => ({
11
12
  getOpenclawClawlingClient: getClientMock,
12
13
  getOpenclawClawlingRuntime: getRuntimeMock,
14
+ waitForOpenclawClawlingClient: waitForClientMock,
13
15
  startOpenclawClawlingGateway: vi.fn(),
14
16
  }));
15
17
 
@@ -31,12 +33,50 @@ describe("openclaw-clawchat channel outbound", () => {
31
33
  vi.resetModules();
32
34
  getClientMock.mockReset();
33
35
  getRuntimeMock.mockReset();
36
+ waitForClientMock.mockReset();
34
37
  uploadOutboundMediaMock.mockReset();
35
38
  createApiClientMock.mockReset();
36
39
  sendTextMock.mockReset();
37
40
  sendMediaMock.mockReset();
38
41
  });
39
42
 
43
+ it("sendText waits for client activation when no active client exists yet", async () => {
44
+ const client = { sendMessage: vi.fn() };
45
+ getClientMock.mockReturnValue(undefined);
46
+ waitForClientMock.mockResolvedValue(client);
47
+ sendTextMock.mockResolvedValue({ messageId: "m-2", acceptedAt: 456 });
48
+
49
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
50
+ const result = await openclawClawlingOutbound.sendText!({
51
+ cfg: {
52
+ channels: {
53
+ "openclaw-clawchat": {
54
+ enabled: true,
55
+ websocketUrl: "ws://t",
56
+ baseUrl: "https://api.example.com",
57
+ token: "tk",
58
+ userId: "agent-1",
59
+ },
60
+ },
61
+ } as never,
62
+ to: "cc:user-1",
63
+ text: "hello",
64
+ });
65
+
66
+ expect(waitForClientMock).toHaveBeenCalledWith("default");
67
+ expect(sendTextMock).toHaveBeenCalledWith({
68
+ client,
69
+ account: expect.objectContaining({ userId: "agent-1" }),
70
+ to: { chatId: "user-1", chatType: "direct" },
71
+ text: "hello",
72
+ });
73
+ expect(result).toEqual({
74
+ channel: "openclaw-clawchat",
75
+ to: "cc:user-1",
76
+ messageId: "m-2",
77
+ });
78
+ });
79
+
40
80
  it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
41
81
  const client = { sendMessage: vi.fn() };
42
82
  const runtime = { media: { loadWebMedia: vi.fn() } };
@@ -113,4 +153,51 @@ describe("openclaw-clawchat channel outbound", () => {
113
153
  }),
114
154
  ).rejects.toThrow(/requires mediaUrl/);
115
155
  });
156
+
157
+ it("sendMedia waits for client activation when no active client exists yet", async () => {
158
+ const client = { sendMessage: vi.fn() };
159
+ const runtime = { media: { loadWebMedia: vi.fn() } };
160
+ const apiClient = { uploadMedia: vi.fn() };
161
+ getClientMock.mockReturnValue(undefined);
162
+ waitForClientMock.mockResolvedValue(client);
163
+ getRuntimeMock.mockReturnValue(runtime);
164
+ createApiClientMock.mockReturnValue(apiClient);
165
+ uploadOutboundMediaMock.mockResolvedValue([
166
+ { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
167
+ ]);
168
+ sendMediaMock.mockResolvedValue({ messageId: "m-3", acceptedAt: 789 });
169
+
170
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
171
+ const result = await openclawClawlingOutbound.sendMedia!({
172
+ cfg: {
173
+ channels: {
174
+ "openclaw-clawchat": {
175
+ enabled: true,
176
+ websocketUrl: "ws://t",
177
+ baseUrl: "https://api.example.com",
178
+ token: "tk",
179
+ userId: "agent-1",
180
+ },
181
+ },
182
+ } as never,
183
+ to: "cc:group:room-1",
184
+ text: "caption",
185
+ mediaUrl: "/tmp/photo.png",
186
+ mediaLocalRoots: ["/tmp"],
187
+ });
188
+
189
+ expect(waitForClientMock).toHaveBeenCalledWith("default");
190
+ expect(sendMediaMock).toHaveBeenCalledWith({
191
+ client,
192
+ account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
193
+ to: { chatId: "room-1", chatType: "group" },
194
+ text: "caption",
195
+ mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
196
+ });
197
+ expect(result).toEqual({
198
+ channel: "openclaw-clawchat",
199
+ to: "cc:group:room-1",
200
+ messageId: "m-3",
201
+ });
202
+ });
116
203
  });
package/src/outbound.ts CHANGED
@@ -9,7 +9,11 @@ import { createOpenclawClawlingApiClient } from "./api-client.ts";
9
9
  import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
10
10
  import { textToFragments } from "./message-mapper.ts";
11
11
  import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
12
- import { getOpenclawClawlingClient, getOpenclawClawlingRuntime } from "./runtime.ts";
12
+ import {
13
+ getOpenclawClawlingClient,
14
+ getOpenclawClawlingRuntime,
15
+ waitForOpenclawClawlingClient,
16
+ } from "./runtime.ts";
13
17
 
14
18
  export interface OutboundTarget {
15
19
  chatId: string;
@@ -198,10 +202,9 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
198
202
  channel: CHANNEL_ID,
199
203
  sendText: async ({ cfg, to, text }) => {
200
204
  const account = resolveOpenclawClawlingAccount(cfg);
201
- const client = getOpenclawClawlingClient(account.accountId);
202
- if (!client) {
203
- throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
204
- }
205
+ const client =
206
+ getOpenclawClawlingClient(account.accountId) ??
207
+ (await waitForOpenclawClawlingClient(account.accountId));
205
208
  const result = await sendOpenclawClawlingText({
206
209
  client,
207
210
  account,
@@ -215,10 +218,9 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
215
218
  },
216
219
  sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
217
220
  const account = resolveOpenclawClawlingAccount(cfg);
218
- const client = getOpenclawClawlingClient(account.accountId);
219
- if (!client) {
220
- throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
221
- }
221
+ const client =
222
+ getOpenclawClawlingClient(account.accountId) ??
223
+ (await waitForOpenclawClawlingClient(account.accountId));
222
224
  if (!mediaUrl?.trim()) {
223
225
  throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
224
226
  }
@@ -0,0 +1,252 @@
1
+ import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
4
+
5
+ describe("openclaw-clawchat reply-dispatcher", () => {
6
+ it("emits message.failed in stream mode even if execution errors before any stream chunk", async () => {
7
+ let hooks:
8
+ | {
9
+ deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
10
+ onIdle?: () => Promise<void>;
11
+ onError?: (error: unknown, info: { kind: string }) => void;
12
+ }
13
+ | undefined;
14
+ const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
15
+ const client = {
16
+ opts: {
17
+ transport: {
18
+ send: (data: string) => {
19
+ const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
20
+ sent.push({ event: env.event, payload: env.payload });
21
+ },
22
+ },
23
+ traceIdFactory: () => "trace-1",
24
+ },
25
+ typing: vi.fn(),
26
+ } as unknown as ClawlingChatClient;
27
+
28
+ createOpenclawClawlingReplyDispatcher({
29
+ cfg: {} as never,
30
+ runtime: {
31
+ channel: {
32
+ reply: {
33
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
34
+ createReplyDispatcherWithTyping: vi.fn((options) => {
35
+ hooks = options;
36
+ return {
37
+ dispatcher: {},
38
+ replyOptions: {},
39
+ markDispatchIdle: vi.fn(),
40
+ };
41
+ }),
42
+ },
43
+ },
44
+ } as never,
45
+ account: {
46
+ accountId: "default",
47
+ userId: "agent-1",
48
+ replyMode: "stream",
49
+ forwardThinking: true,
50
+ forwardToolCalls: false,
51
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
52
+ } as never,
53
+ client,
54
+ target: { chatId: "chat-1", chatType: "direct" },
55
+ log: { info: vi.fn(), error: vi.fn() },
56
+ });
57
+
58
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
59
+
60
+ expect(sent).toHaveLength(1);
61
+ expect(sent[0]!.event).toBe("message.failed");
62
+ expect(sent[0]!.payload.reason).toBe("Error: boom");
63
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
64
+ ["chat-1", false, "direct"],
65
+ ]);
66
+ });
67
+
68
+ it("does not emit consolidated final after a streaming failure", async () => {
69
+ let hooks:
70
+ | {
71
+ deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
72
+ onIdle?: () => Promise<void>;
73
+ onError?: (error: unknown, info: { kind: string }) => void;
74
+ }
75
+ | undefined;
76
+ const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
77
+ const client = {
78
+ opts: {
79
+ transport: {
80
+ send: (data: string) => {
81
+ const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
82
+ sent.push({ event: env.event, payload: env.payload });
83
+ },
84
+ },
85
+ traceIdFactory: () => "trace-2",
86
+ },
87
+ typing: vi.fn(),
88
+ } as unknown as ClawlingChatClient;
89
+
90
+ createOpenclawClawlingReplyDispatcher({
91
+ cfg: {} as never,
92
+ runtime: {
93
+ channel: {
94
+ reply: {
95
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
96
+ createReplyDispatcherWithTyping: vi.fn((options) => {
97
+ hooks = options;
98
+ return {
99
+ dispatcher: {},
100
+ replyOptions: {},
101
+ markDispatchIdle: vi.fn(),
102
+ };
103
+ }),
104
+ },
105
+ },
106
+ } as never,
107
+ account: {
108
+ accountId: "default",
109
+ userId: "agent-1",
110
+ replyMode: "stream",
111
+ forwardThinking: true,
112
+ forwardToolCalls: false,
113
+ stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
114
+ } as never,
115
+ client,
116
+ target: { chatId: "chat-1", chatType: "direct" },
117
+ inboundMessageId: "inbound-1",
118
+ inboundForFinalReply: {
119
+ senderId: "user-1",
120
+ senderNickName: "User 1",
121
+ bodyText: "hello",
122
+ },
123
+ log: { info: vi.fn(), error: vi.fn() },
124
+ });
125
+
126
+ await hooks?.deliver?.({ text: "partial reply" }, { kind: "block" });
127
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
128
+ await hooks?.onIdle?.();
129
+
130
+ expect(sent.map((entry) => entry.event)).toEqual([
131
+ "message.created",
132
+ "message.add",
133
+ "message.failed",
134
+ ]);
135
+ expect(sent.find((entry) => entry.event === "message.reply")).toBeUndefined();
136
+ expect(sent.find((entry) => entry.event === "message.done")).toBeUndefined();
137
+ });
138
+
139
+ it("sends static error text when non-streaming reply execution fails", async () => {
140
+ let hooks:
141
+ | {
142
+ onError?: (error: unknown, info: { kind: string }) => void;
143
+ }
144
+ | undefined;
145
+ const client = {
146
+ sendMessage: vi.fn().mockResolvedValue({
147
+ payload: { message_id: "server-m1", accepted_at: 1234 },
148
+ }),
149
+ replyMessage: vi.fn(),
150
+ typing: vi.fn(),
151
+ } as unknown as ClawlingChatClient;
152
+
153
+ createOpenclawClawlingReplyDispatcher({
154
+ cfg: {} as never,
155
+ runtime: {
156
+ channel: {
157
+ reply: {
158
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
159
+ createReplyDispatcherWithTyping: vi.fn((options) => {
160
+ hooks = options;
161
+ return {
162
+ dispatcher: {},
163
+ replyOptions: {},
164
+ markDispatchIdle: vi.fn(),
165
+ };
166
+ }),
167
+ },
168
+ },
169
+ } as never,
170
+ account: {
171
+ accountId: "default",
172
+ userId: "agent-1",
173
+ replyMode: "static",
174
+ forwardThinking: true,
175
+ forwardToolCalls: false,
176
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
177
+ } as never,
178
+ client,
179
+ target: { chatId: "chat-1", chatType: "direct" },
180
+ log: { info: vi.fn(), error: vi.fn() },
181
+ });
182
+
183
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
184
+ await new Promise((resolve) => setTimeout(resolve, 0));
185
+
186
+ expect(client.sendMessage).toHaveBeenCalledWith(
187
+ expect.objectContaining({
188
+ to: { id: "chat-1", type: "direct" },
189
+ body: { fragments: [{ kind: "text", text: "Error: boom" }] },
190
+ }),
191
+ );
192
+ expect(client.replyMessage).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("strips delivery retry wrapper text before sending non-streaming errors", async () => {
196
+ let hooks:
197
+ | {
198
+ onError?: (error: unknown, info: { kind: string }) => void;
199
+ }
200
+ | undefined;
201
+ const client = {
202
+ sendMessage: vi.fn().mockResolvedValue({
203
+ payload: { message_id: "server-m1", accepted_at: 1234 },
204
+ }),
205
+ replyMessage: vi.fn(),
206
+ typing: vi.fn(),
207
+ } as unknown as ClawlingChatClient;
208
+
209
+ createOpenclawClawlingReplyDispatcher({
210
+ cfg: {} as never,
211
+ runtime: {
212
+ channel: {
213
+ reply: {
214
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
215
+ createReplyDispatcherWithTyping: vi.fn((options) => {
216
+ hooks = options;
217
+ return {
218
+ dispatcher: {},
219
+ replyOptions: {},
220
+ markDispatchIdle: vi.fn(),
221
+ };
222
+ }),
223
+ },
224
+ },
225
+ } as never,
226
+ account: {
227
+ accountId: "default",
228
+ userId: "agent-1",
229
+ replyMode: "static",
230
+ forwardThinking: true,
231
+ forwardToolCalls: false,
232
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
233
+ } as never,
234
+ client,
235
+ target: { chatId: "chat-1", chatType: "direct" },
236
+ log: { info: vi.fn(), error: vi.fn() },
237
+ });
238
+
239
+ hooks?.onError?.(
240
+ new Error("Retry failed for delivery 123: Error: boom"),
241
+ { kind: "dispatch" },
242
+ );
243
+ await new Promise((resolve) => setTimeout(resolve, 0));
244
+
245
+ expect(client.sendMessage).toHaveBeenCalledWith(
246
+ expect.objectContaining({
247
+ to: { id: "chat-1", type: "direct" },
248
+ body: { fragments: [{ kind: "text", text: "Error: boom" }] },
249
+ }),
250
+ );
251
+ });
252
+ });
@@ -13,6 +13,7 @@ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
13
13
  import { textToFragments } from "./message-mapper.ts";
14
14
  import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
15
15
  import { sendOpenclawClawlingText, type OutboundReplyCtx } from "./outbound.ts";
16
+ import { sendStreamingFailure } from "./streaming.ts";
16
17
 
17
18
  export interface ReplyDispatcherOptions {
18
19
  cfg: OpenClawConfig;
@@ -54,6 +55,15 @@ type StreamingReplyHooks = {
54
55
  onReasoningStream?: (payload: ReplyPayload) => Promise<void>;
55
56
  };
56
57
 
58
+ function normalizeReplyErrorText(error: unknown): string {
59
+ const raw = String(error);
60
+ const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
61
+ if (retryWrapped?.[1]?.trim()) return retryWrapped[1].trim();
62
+ const retryWrappedBare = raw.match(/^Retry failed for delivery [^:]+:\s*(.+)$/s);
63
+ if (retryWrappedBare?.[1]?.trim()) return retryWrappedBare[1].trim();
64
+ return raw;
65
+ }
66
+
57
67
  /**
58
68
  * Reply dispatcher for openclaw-clawchat.
59
69
  *
@@ -126,6 +136,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
126
136
  const accumulatedMediaUrls: string[] = [];
127
137
  let finalEmitted = false;
128
138
  let streamingClosed = false;
139
+ let runFailed = false;
129
140
  let runDone = false;
130
141
  // `streamCreatedEmitted` is the authoritative guard: once a `message.created`
131
142
  // has been emitted for this dispatcher instance, never emit another — even
@@ -133,6 +144,9 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
133
144
  // raced the lazy open path.
134
145
  let streamCreatedEmitted = false;
135
146
 
147
+ const mintStreamingMessageId = () =>
148
+ `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
149
+
136
150
  const openSessionIfNeeded = () => {
137
151
  if (!streamingEnabled || streamingSession || streamCreatedEmitted) return;
138
152
  streamCreatedEmitted = true;
@@ -143,7 +157,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
143
157
  // which mints its own id. The inbound user message_id lives on
144
158
  // `replyTo.msgId`; keeping the two distinct avoids the agent's reply
145
159
  // frames shadowing the user turn they answer.
146
- streamingMessageId = `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
160
+ streamingMessageId = mintStreamingMessageId();
147
161
  streamingSession = openBufferedStreamingSession({
148
162
  client,
149
163
  routing,
@@ -352,15 +366,35 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
352
366
  }
353
367
  },
354
368
  onError: (error: unknown, info: { kind: string }) => {
369
+ const errorText = normalizeReplyErrorText(error);
355
370
  log?.error?.(
356
- `[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${String(error)}`,
371
+ `[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${errorText}`,
357
372
  );
358
- void closeStreamingSession("fail", String(error));
373
+ if (!streamingEnabled) {
374
+ void sendStatic(errorText);
375
+ return;
376
+ }
377
+ runFailed = true;
378
+ if (streamingSession && !streamingClosed) {
379
+ void closeStreamingSession("fail", errorText);
380
+ return;
381
+ }
382
+ if (streamingClosed) return;
383
+ streamingClosed = true;
384
+ if (!streamingMessageId) streamingMessageId = mintStreamingMessageId();
385
+ void sendStreamingFailure({
386
+ client,
387
+ routing,
388
+ messageId: streamingMessageId,
389
+ currentSequence: 0,
390
+ reason: errorText,
391
+ });
359
392
  },
360
393
  onIdle: async () => {
361
394
  if (runDone) return;
362
395
  runDone = true;
363
396
  if (!streamingEnabled) return;
397
+ if (runFailed) return;
364
398
  await closeStreamingSession("done");
365
399
  await emitFinalConsolidatedMessage();
366
400
  },
@@ -349,6 +349,156 @@ describe("openclaw-clawchat runtime media ingest", () => {
349
349
  });
350
350
  });
351
351
 
352
+ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
353
+ it("marks dispatch idle when reply dispatch fails", async () => {
354
+ const markDispatchIdle = vi.fn();
355
+ const withReplyDispatcher = vi.fn(
356
+ async (opts: { run: () => Promise<unknown>; onSettled?: () => void | Promise<void> }) => {
357
+ try {
358
+ await opts.run();
359
+ } finally {
360
+ await opts.onSettled?.();
361
+ }
362
+ },
363
+ );
364
+ const dispatchReplyFromConfig = vi.fn().mockRejectedValue(new Error("dispatch boom"));
365
+ const logError = vi.fn();
366
+
367
+ const runtime = {
368
+ channel: {
369
+ routing: {
370
+ resolveAgentRoute: vi.fn(() => ({
371
+ agentId: "u",
372
+ accountId: "default",
373
+ sessionKey: "s",
374
+ })),
375
+ },
376
+ session: {
377
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
378
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
379
+ },
380
+ reply: {
381
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
382
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
383
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
384
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
385
+ createReplyDispatcherWithTyping: vi.fn(() => ({
386
+ dispatcher: {},
387
+ replyOptions: {},
388
+ markDispatchIdle,
389
+ })),
390
+ withReplyDispatcher,
391
+ dispatchReplyFromConfig,
392
+ },
393
+ media: {
394
+ fetchRemoteMedia: vi.fn(),
395
+ saveMediaBuffer: vi.fn(),
396
+ loadWebMedia: vi.fn(),
397
+ },
398
+ },
399
+ } as unknown as PluginRuntime;
400
+
401
+ setOpenclawClawlingRuntime(runtime);
402
+
403
+ const { startOpenclawClawlingGateway } = await import("./runtime.ts");
404
+ const transport = new MockTransport();
405
+ const abortController = new AbortController();
406
+
407
+ const startPromise = startOpenclawClawlingGateway({
408
+ cfg: {} as OpenClawConfig,
409
+ account: {
410
+ accountId: "default",
411
+ name: "openclaw-clawchat",
412
+ enabled: true,
413
+ configured: true,
414
+ websocketUrl: "ws://t",
415
+ baseUrl: "https://api.example.com",
416
+ token: "tk",
417
+ userId: "u",
418
+ replyMode: "static",
419
+ forwardThinking: true,
420
+ forwardToolCalls: false,
421
+ allowFrom: [],
422
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
423
+ reconnect: {
424
+ initialDelay: 1000,
425
+ maxDelay: 30000,
426
+ jitterRatio: 0.3,
427
+ maxRetries: Number.POSITIVE_INFINITY,
428
+ },
429
+ heartbeat: { interval: 25000, timeout: 10000 },
430
+ ack: { timeout: 10000, autoResendOnTimeout: false },
431
+ },
432
+ abortSignal: abortController.signal,
433
+ setStatus: vi.fn(),
434
+ getStatus: vi.fn(() => ({ accountId: "default" })),
435
+ log: { info: vi.fn(), error: logError },
436
+ transport,
437
+ });
438
+
439
+ await new Promise((r) => setTimeout(r, 0));
440
+ transport.emitInbound(
441
+ JSON.stringify({
442
+ version: "2",
443
+ event: "connect.challenge",
444
+ trace_id: "tc",
445
+ emitted_at: Date.now(),
446
+ payload: { nonce: "n" },
447
+ }),
448
+ );
449
+ transport.emitInbound(
450
+ JSON.stringify({
451
+ version: "2",
452
+ event: "hello-ok",
453
+ trace_id: "th",
454
+ emitted_at: Date.now(),
455
+ payload: {},
456
+ }),
457
+ );
458
+ await new Promise((r) => setTimeout(r, 5));
459
+
460
+ transport.emitInbound(
461
+ JSON.stringify({
462
+ version: "2",
463
+ event: "message.send",
464
+ trace_id: "tm",
465
+ emitted_at: Date.now(),
466
+ chat_id: "chat-1",
467
+ chat_type: "direct",
468
+ to: { id: "u", type: "direct" },
469
+ sender: { sender_id: "user-1", type: "direct", display_name: "User" },
470
+ payload: {
471
+ message_id: "m-fail",
472
+ message_mode: "normal",
473
+ message: {
474
+ body: {
475
+ fragments: [{ kind: "text", text: "hello" }],
476
+ },
477
+ context: { mentions: [], reply: null },
478
+ streaming: {
479
+ status: "static",
480
+ sequence: 0,
481
+ mutation_policy: "sealed",
482
+ started_at: null,
483
+ completed_at: null,
484
+ },
485
+ },
486
+ },
487
+ }),
488
+ );
489
+
490
+ await new Promise((r) => setTimeout(r, 30));
491
+ abortController.abort();
492
+ await startPromise;
493
+
494
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
495
+ expect(markDispatchIdle).toHaveBeenCalledTimes(1);
496
+ expect(logError).toHaveBeenCalledWith(
497
+ expect.stringContaining("openclaw-clawchat message handler error:"),
498
+ );
499
+ });
500
+ });
501
+
352
502
  describe("openclaw-clawchat runtime connect flow", () => {
353
503
  it("completes connect through MockTransport handshake", async () => {
354
504
  const { startOpenclawClawlingGateway } = await import("./runtime.ts");
package/src/runtime.ts CHANGED
@@ -19,6 +19,7 @@ import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
19
19
  import { fetchInboundMedia } from "./media-runtime.ts";
20
20
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
21
21
  import { sendStreamingText } from "./streaming.ts";
22
+ import { sendOpenclawClawlingText } from "./outbound.ts";
22
23
 
23
24
  type Log = { info?: (m: string) => void; error?: (m: string) => void };
24
25
 
@@ -33,6 +34,28 @@ export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient
33
34
  return activeClients.get(accountId);
34
35
  }
35
36
 
37
+ export async function waitForOpenclawClawlingClient(
38
+ accountId: string,
39
+ options: { timeoutMs?: number; pollMs?: number } = {},
40
+ ): Promise<ClawlingChatClient> {
41
+ const timeoutMs = options.timeoutMs ?? 15_000;
42
+ const pollMs = options.pollMs ?? 100;
43
+ const deadline = Date.now() + timeoutMs;
44
+
45
+ for (;;) {
46
+ const client = activeClients.get(accountId);
47
+ if (client && (client as { state?: string }).state === "connected") {
48
+ return client;
49
+ }
50
+ if (Date.now() >= deadline) {
51
+ throw new Error(
52
+ `openclaw-clawchat client did not activate within ${timeoutMs}ms for account ${accountId}`,
53
+ );
54
+ }
55
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
56
+ }
57
+ }
58
+
36
59
  export type ClawlingState =
37
60
  | "idle"
38
61
  | "connecting"
@@ -138,8 +161,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
138
161
  }
139
162
  });
140
163
 
141
- client.on("message", (env: Envelope) => {
142
- dispatchOpenclawClawlingInbound({
164
+ client.on("message", async (env: Envelope) => {
165
+ try {
166
+ await dispatchOpenclawClawlingInbound({
143
167
  envelope: env as Envelope<DownlinkMessageSendPayload>,
144
168
  cfg,
145
169
  runtime,
@@ -238,14 +262,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
238
262
  log?.info?.(
239
263
  `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
240
264
  );
241
- const dispatchResult = await rt.reply.withReplyDispatcher({
242
- dispatcher,
243
- onSettled: () => markDispatchIdle(),
244
- run: () =>
245
- rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
246
- });
265
+
247
266
  try {
248
-
267
+ const dispatchResult = await rt.reply.withReplyDispatcher({
268
+ dispatcher,
269
+ onSettled: () => markDispatchIdle(),
270
+ run: () =>
271
+ rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
272
+ });
249
273
  const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
250
274
  const queuedFinal = Boolean(
251
275
  (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
@@ -264,14 +288,24 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
264
288
  log?.error?.(
265
289
  `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
266
290
  );
267
- throw err;
291
+ await sendOpenclawClawlingText({
292
+ client,
293
+ account: turn.account,
294
+ to: {
295
+ chatId: turn.peer.id,
296
+ chatType: turn.peer.kind === "group" ? "group" : "direct",
297
+ },
298
+ text: String(err),
299
+ ...(turn.replyCtx ? { replyCtx: turn.replyCtx } : {}),
300
+ });
268
301
  }
269
302
  },
270
- }).catch((err) => {
303
+ })
304
+ } catch (err) {
271
305
  log?.error?.(
272
- `[${accountId}] openclaw-clawchat inbound dispatch error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
306
+ `[${accountId}] openclaw-clawchat message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
273
307
  );
274
- });
308
+ }
275
309
  });
276
310
 
277
311
  // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`