@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13

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.
Files changed (85) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +121 -19
  3. package/dist/index.js +10 -19
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +78 -10
  6. package/dist/src/api-types.test-d.js +10 -0
  7. package/dist/src/channel.js +25 -156
  8. package/dist/src/channel.setup.js +120 -0
  9. package/dist/src/client.js +37 -41
  10. package/dist/src/config.js +75 -17
  11. package/dist/src/inbound.js +79 -61
  12. package/dist/src/login.runtime.js +84 -19
  13. package/dist/src/media-runtime.js +8 -8
  14. package/dist/src/message-mapper.js +1 -1
  15. package/dist/src/mock-transport.js +31 -0
  16. package/dist/src/outbound.js +410 -26
  17. package/dist/src/protocol-types.js +63 -0
  18. package/dist/src/protocol-types.typecheck.js +1 -0
  19. package/dist/src/protocol.js +2 -7
  20. package/dist/src/reply-dispatcher.js +157 -54
  21. package/dist/src/runtime.js +795 -119
  22. package/dist/src/storage.js +689 -0
  23. package/dist/src/tools-schema.js +98 -16
  24. package/dist/src/tools.js +422 -135
  25. package/dist/src/ws-alignment.js +178 -0
  26. package/dist/src/ws-client.js +588 -0
  27. package/dist/src/ws-log.js +19 -0
  28. package/index.ts +10 -22
  29. package/openclaw.plugin.json +37 -2
  30. package/package.json +17 -4
  31. package/setup-entry.ts +4 -0
  32. package/skills/clawchat/SKILL.md +88 -0
  33. package/src/api-client.test.ts +274 -14
  34. package/src/api-client.ts +138 -23
  35. package/src/api-types.test-d.ts +12 -0
  36. package/src/api-types.ts +90 -4
  37. package/src/buffered-stream.test.ts +14 -12
  38. package/src/buffered-stream.ts +1 -1
  39. package/src/channel.outbound.test.ts +269 -60
  40. package/src/channel.setup.ts +146 -0
  41. package/src/channel.test.ts +130 -24
  42. package/src/channel.ts +30 -186
  43. package/src/client.test.ts +197 -11
  44. package/src/client.ts +50 -57
  45. package/src/config.test.ts +108 -6
  46. package/src/config.ts +95 -24
  47. package/src/inbound.test.ts +288 -37
  48. package/src/inbound.ts +96 -84
  49. package/src/login.runtime.test.ts +347 -13
  50. package/src/login.runtime.ts +105 -23
  51. package/src/manifest.test.ts +146 -74
  52. package/src/media-runtime.test.ts +57 -2
  53. package/src/media-runtime.ts +26 -17
  54. package/src/message-mapper.test.ts +2 -2
  55. package/src/message-mapper.ts +2 -2
  56. package/src/mock-transport.test.ts +35 -0
  57. package/src/mock-transport.ts +38 -0
  58. package/src/outbound.test.ts +694 -73
  59. package/src/outbound.ts +484 -31
  60. package/src/plugin-entry.test.ts +1 -0
  61. package/src/protocol-types.test.ts +69 -0
  62. package/src/protocol-types.ts +296 -0
  63. package/src/protocol-types.typecheck.ts +89 -0
  64. package/src/protocol.test.ts +1 -6
  65. package/src/protocol.ts +2 -7
  66. package/src/reply-dispatcher.test.ts +819 -119
  67. package/src/reply-dispatcher.ts +202 -60
  68. package/src/runtime.test.ts +2120 -41
  69. package/src/runtime.ts +935 -142
  70. package/src/scripts.test.ts +85 -0
  71. package/src/storage.test.ts +793 -0
  72. package/src/storage.ts +1095 -0
  73. package/src/streaming.test.ts +9 -8
  74. package/src/streaming.ts +1 -1
  75. package/src/tools-schema.ts +148 -20
  76. package/src/tools.test.ts +377 -50
  77. package/src/tools.ts +574 -154
  78. package/src/ws-alignment.test.ts +103 -0
  79. package/src/ws-alignment.ts +275 -0
  80. package/src/ws-client.test.ts +1218 -0
  81. package/src/ws-client.ts +662 -0
  82. package/src/ws-log.test.ts +32 -0
  83. package/src/ws-log.ts +31 -0
  84. package/skills/clawchat-account-tools/SKILL.md +0 -26
  85. package/skills/clawchat-activate/SKILL.md +0 -47
@@ -1,15 +1,220 @@
1
- import { MockTransport, AuthError } from "@newbase-clawchat/sdk";
2
1
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
3
2
  import { describe, expect, it, vi } from "vitest";
3
+ import { MockTransport } from "./mock-transport.ts";
4
+ import { AuthError } from "./protocol-types.ts";
4
5
  import {
5
6
  classifyClawlingClientError,
6
7
  mapClawlingStateToStatus,
7
8
  setOpenclawClawlingRuntime,
8
9
  getOpenclawClawlingRuntime,
10
+ startOpenclawClawlingGateway,
11
+ getOpenclawClawlingClient,
9
12
  } from "./runtime.ts";
13
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
14
+ import { sendOpenclawClawlingText } from "./outbound.ts";
15
+
16
+ function baseAccount(
17
+ overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
18
+ ): ResolvedOpenclawClawlingAccount {
19
+ return {
20
+ accountId: "default",
21
+ name: "openclaw-clawchat",
22
+ enabled: true,
23
+ configured: true,
24
+ websocketUrl: "ws://t",
25
+ baseUrl: "https://api.example.com",
26
+ token: "tk",
27
+ userId: "u",
28
+ ownerUserId: "owner-u",
29
+ replyMode: "static",
30
+ groupMode: "all",
31
+ groups: {},
32
+ forwardThinking: true,
33
+ forwardToolCalls: false,
34
+ richInteractions: false,
35
+ allowFrom: [],
36
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
37
+ reconnect: {
38
+ initialDelay: 1000,
39
+ maxDelay: 30000,
40
+ jitterRatio: 0.3,
41
+ maxRetries: Number.POSITIVE_INFINITY,
42
+ },
43
+ heartbeat: { interval: 25000, timeout: 10000 },
44
+ ack: { timeout: 10000, autoResendOnTimeout: false },
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ const EXPECTED_ACTIVATION_BOOTSTRAP_TEXT = [
50
+ "ClawChat activation bootstrap: You are now connected to this ClawChat direct conversation.",
51
+ "Please do both:",
52
+ "1. Send a brief, friendly greeting to the user in this ClawChat direct conversation.",
53
+ "2. If you have local profile information for yourself, such as display name, bio, or avatar, update the connected ClawChat account profile using the available ClawChat tools. Use `clawchat_update_account_profile` for display name/bio/avatar URL, and use `clawchat_upload_avatar_image` first if the avatar is only available as a local image path. If you do not have local profile information, skip profile updates and only greet the user.",
54
+ "Do not ask the user for profile information just for this bootstrap.",
55
+ ].join("\n");
56
+
57
+ const EXPECTED_CLAWCHAT_GROUP_SYSTEM_PROMPT = [
58
+ "When replying in a ClawChat group, treat the conversation as a shared space. Stay concise, respect the group topic, and avoid exposing hidden runtime context.",
59
+ "",
60
+ "**给 Agents**",
61
+ "你是群组里的伙伴。请理解我们在做的事,理解每一位成员。",
62
+ "也请通过群慢慢和每个人建立关系——你是我们的一员。",
63
+ "",
64
+ "**两条底线**",
65
+ "1. 被 @ 时必须回复",
66
+ "2. Agent 之间连续对话不超过 10 轮,除非有人类伙伴明确允许",
67
+ ].join("\n");
68
+
69
+ function buildTestInboundContext(params: {
70
+ channel: string;
71
+ accountId?: string;
72
+ provider?: string;
73
+ surface?: string;
74
+ messageId?: string;
75
+ messageIdFull?: string;
76
+ timestamp?: number;
77
+ from: string;
78
+ sender: { id: string; name?: string; displayLabel?: string };
79
+ conversation: { kind: "direct" | "group" | "channel"; label?: string };
80
+ route: { accountId?: string; routeSessionKey: string; dispatchSessionKey?: string };
81
+ reply: { to: string; originatingTo: string };
82
+ message: { body?: string; rawBody: string; bodyForAgent?: string; commandBody?: string };
83
+ access?: { mentions?: { wasMentioned?: boolean } };
84
+ supplemental?: { groupSystemPrompt?: string };
85
+ }) {
86
+ return {
87
+ Body: params.message.body ?? params.message.rawBody,
88
+ BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
89
+ RawBody: params.message.rawBody,
90
+ CommandBody: params.message.commandBody ?? params.message.rawBody,
91
+ From: params.from,
92
+ To: params.reply.to,
93
+ SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
94
+ AccountId: params.route.accountId ?? params.accountId,
95
+ MessageSid: params.messageId,
96
+ MessageSidFull: params.messageIdFull,
97
+ ChatType: params.conversation.kind,
98
+ ConversationLabel: params.conversation.label,
99
+ GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
100
+ GroupSystemPrompt: params.supplemental?.groupSystemPrompt,
101
+ SenderName: params.sender.name ?? params.sender.displayLabel,
102
+ SenderId: params.sender.id,
103
+ Timestamp: params.timestamp,
104
+ Provider: params.provider ?? params.channel,
105
+ Surface: params.surface ?? params.provider ?? params.channel,
106
+ WasMentioned: params.access?.mentions?.wasMentioned,
107
+ OriginatingChannel: params.channel,
108
+ OriginatingTo: params.reply.originatingTo,
109
+ };
110
+ }
111
+
112
+ async function completeHandshake(
113
+ transport: MockTransport,
114
+ challengeTraceId = "challenge-bootstrap",
115
+ helloPayload: Record<string, unknown> = {},
116
+ ): Promise<Record<string, unknown>> {
117
+ await Promise.resolve();
118
+ transport.emitInbound(
119
+ JSON.stringify({
120
+ version: "2",
121
+ event: "connect.challenge",
122
+ trace_id: challengeTraceId,
123
+ emitted_at: Date.now(),
124
+ payload: { nonce: `${challengeTraceId}-nonce` },
125
+ }),
126
+ );
127
+ const connectFrame = transport.sent
128
+ .map((raw) => JSON.parse(raw) as Record<string, unknown>)
129
+ .filter((env) => env.event === "connect")
130
+ .at(-1)!;
131
+ transport.emitInbound(
132
+ JSON.stringify({
133
+ version: "2",
134
+ event: "hello-ok",
135
+ trace_id: connectFrame.trace_id,
136
+ emitted_at: Date.now(),
137
+ payload: helloPayload,
138
+ }),
139
+ );
140
+ await Promise.resolve();
141
+ return connectFrame;
142
+ }
143
+
144
+ function jsonEnvelope(data: unknown, status = 200): Response {
145
+ return new Response(JSON.stringify(data), {
146
+ status,
147
+ headers: { "content-type": "application/json" },
148
+ });
149
+ }
150
+
151
+ function conversationDetails(id: string, overrides: Record<string, unknown> = {}) {
152
+ return {
153
+ id,
154
+ type: "group",
155
+ title: `Room ${id}`,
156
+ description: `Description ${id}`,
157
+ creator_id: "user-owner",
158
+ created_at: "2026-05-21T10:00:00.000Z",
159
+ updated_at: "2026-05-21T10:01:00.000Z",
160
+ participants: [
161
+ {
162
+ conversation_id: id,
163
+ user_id: "user-owner",
164
+ role: "owner",
165
+ joined_at: "2026-05-21T10:00:30.000Z",
166
+ nickname: "Owner",
167
+ avatar_url: "https://cdn.example/owner.png",
168
+ },
169
+ ],
170
+ ...overrides,
171
+ };
172
+ }
173
+
174
+ function buildNoDispatchRuntime(dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined)) {
175
+ return {
176
+ channel: {
177
+ routing: {
178
+ resolveAgentRoute: vi.fn(() => ({
179
+ agentId: "default",
180
+ accountId: "default",
181
+ sessionKey: "session-from-route",
182
+ })),
183
+ },
184
+ session: {
185
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
186
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
187
+ },
188
+ reply: {
189
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
190
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
191
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
192
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
193
+ createReplyDispatcherWithTyping: vi.fn(() => ({
194
+ dispatcher: {},
195
+ replyOptions: {},
196
+ markDispatchIdle: vi.fn(),
197
+ markRunComplete: vi.fn(),
198
+ })),
199
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
200
+ dispatchReplyFromConfig,
201
+ },
202
+ turn: {
203
+ buildContext: vi.fn((params) =>
204
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
205
+ ),
206
+ },
207
+ media: {
208
+ fetchRemoteMedia: vi.fn(),
209
+ saveMediaBuffer: vi.fn(),
210
+ loadWebMedia: vi.fn(),
211
+ },
212
+ },
213
+ } as unknown as PluginRuntime;
214
+ }
10
215
 
11
216
  describe("openclaw-clawchat runtime helpers", () => {
12
- it("maps SDK states to channel status shape", () => {
217
+ it("maps local client states to channel status shape", () => {
13
218
  expect(mapClawlingStateToStatus("connected")).toMatchObject({
14
219
  connected: true,
15
220
  running: true,
@@ -18,43 +223,1746 @@ describe("openclaw-clawchat runtime helpers", () => {
18
223
  connected: false,
19
224
  running: true,
20
225
  });
21
- expect(mapClawlingStateToStatus("disconnected")).toMatchObject({
22
- connected: false,
23
- running: false,
226
+ expect(mapClawlingStateToStatus("disconnected")).toMatchObject({
227
+ connected: false,
228
+ running: false,
229
+ });
230
+ expect(mapClawlingStateToStatus("connecting")).toMatchObject({
231
+ connected: false,
232
+ running: true,
233
+ });
234
+ });
235
+
236
+ it("classifies AuthError as fatal/no-retry", () => {
237
+ const c = classifyClawlingClientError(new AuthError("bad-token"));
238
+ expect(c.kind).toBe("auth");
239
+ expect(c.retry).toBe(false);
240
+ });
241
+
242
+ it("classifies generic errors as unknown", () => {
243
+ const c = classifyClawlingClientError(new Error("huh"));
244
+ expect(c.kind).toBe("unknown");
245
+ expect(c.retry).toBe(false);
246
+ });
247
+
248
+ it("runtime store round-trips", () => {
249
+ const rt = { mocked: true } as unknown as PluginRuntime;
250
+ setOpenclawClawlingRuntime(rt);
251
+ expect(getOpenclawClawlingRuntime()).toBe(rt);
252
+ });
253
+
254
+ it("logs auth_failed and does not reconnect after hello-fail", async () => {
255
+ const logs: string[] = [];
256
+ const transport = new MockTransport();
257
+ const account = baseAccount();
258
+ const abortController = new AbortController();
259
+
260
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
261
+ const run = startOpenclawClawlingGateway({
262
+ cfg: {},
263
+ account,
264
+ abortSignal: abortController.signal,
265
+ setStatus: () => {},
266
+ getStatus: () => ({ connected: false, configured: true, running: true }),
267
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
268
+ transport,
269
+ });
270
+
271
+ await Promise.resolve();
272
+ transport.emitInbound(
273
+ JSON.stringify({
274
+ version: "2",
275
+ event: "connect.challenge",
276
+ trace_id: "challenge-1",
277
+ emitted_at: Date.now(),
278
+ payload: { nonce: "nonce-1" },
279
+ }),
280
+ );
281
+ const connectFrame = transport.sent
282
+ .map((raw) => JSON.parse(raw))
283
+ .find((env) => env.event === "connect");
284
+ expect(logs).toContain(
285
+ "clawchat.ws event=connect_start account_id=default attempt=1 reconnect_count=0 state=connecting action=connect url=ws://t queue_size=0",
286
+ );
287
+ expect(logs).toContain(
288
+ "clawchat.ws event=challenge_received account_id=default attempt=1 reconnect_count=0 state=handshaking action=send_connect challenge_trace_id=challenge-1 has_nonce=true",
289
+ );
290
+ expect(logs).toContain(
291
+ "clawchat.ws event=connect_sent account_id=default attempt=1 reconnect_count=0 state=handshaking action=await_hello trace_id=" +
292
+ connectFrame.trace_id +
293
+ " device_id=u",
294
+ );
295
+ transport.emitInbound(
296
+ JSON.stringify({
297
+ version: "2",
298
+ event: "hello-fail",
299
+ trace_id: connectFrame.trace_id,
300
+ emitted_at: Date.now(),
301
+ payload: { reason: "authentication failed" },
302
+ }),
303
+ );
304
+
305
+ await run;
306
+
307
+ expect(logs).toContain(
308
+ "clawchat.ws event=auth_failed account_id=default attempt=1 reconnect_count=0 state=auth_failed action=stop_reconnect trace_id=" +
309
+ connectFrame.trace_id +
310
+ " reason=authentication failed",
311
+ );
312
+ expect(logs.some((line) => line.includes("event=handshake_ok"))).toBe(false);
313
+ expect(logs.some((line) => line.includes("event=reconnect_scheduled"))).toBe(false);
314
+ });
315
+
316
+ it("logs canonical websocket lifecycle for the first successful connect", async () => {
317
+ const logs: string[] = [];
318
+ const transport = new MockTransport();
319
+ const abortController = new AbortController();
320
+
321
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
322
+ const run = startOpenclawClawlingGateway({
323
+ cfg: {},
324
+ account: baseAccount(),
325
+ abortSignal: abortController.signal,
326
+ setStatus: () => {},
327
+ getStatus: () => ({ connected: false, configured: true, running: true }),
328
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
329
+ transport,
330
+ });
331
+
332
+ await Promise.resolve();
333
+ transport.emitInbound(
334
+ JSON.stringify({
335
+ version: "2",
336
+ event: "connect.challenge",
337
+ trace_id: "challenge-1",
338
+ emitted_at: Date.now(),
339
+ payload: { nonce: "nonce-1" },
340
+ }),
341
+ );
342
+ const connectFrame = transport.sent
343
+ .map((raw) => JSON.parse(raw))
344
+ .find((env) => env.event === "connect");
345
+ transport.emitInbound(
346
+ JSON.stringify({
347
+ version: "2",
348
+ event: "hello-ok",
349
+ trace_id: connectFrame.trace_id,
350
+ emitted_at: Date.now(),
351
+ payload: {},
352
+ }),
353
+ );
354
+ await Promise.resolve();
355
+
356
+ expect(logs).toContain(
357
+ "clawchat.ws event=connect_start account_id=default attempt=1 reconnect_count=0 state=connecting action=connect url=ws://t queue_size=0",
358
+ );
359
+ expect(logs).toContain(
360
+ "clawchat.ws event=challenge_received account_id=default attempt=1 reconnect_count=0 state=handshaking action=send_connect challenge_trace_id=challenge-1 has_nonce=true",
361
+ );
362
+ expect(logs).toContain(
363
+ "clawchat.ws event=connect_sent account_id=default attempt=1 reconnect_count=0 state=handshaking action=await_hello trace_id=" +
364
+ connectFrame.trace_id +
365
+ " device_id=u",
366
+ );
367
+ expect(logs).toContainEqual(
368
+ expect.stringMatching(
369
+ new RegExp(
370
+ "^clawchat\\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=0 state=ready action=flush_queue trace_id=" +
371
+ connectFrame.trace_id +
372
+ " elapsed_ms=\\d+ queue_size=0$",
373
+ ),
374
+ ),
375
+ );
376
+
377
+ abortController.abort();
378
+ await run;
379
+ });
380
+
381
+ it("records websocket lifecycle calls in connection order", async () => {
382
+ const calls: string[] = [];
383
+ const transport = new MockTransport();
384
+ const abortController = new AbortController();
385
+ const store = {
386
+ startConnection: vi.fn(() => {
387
+ calls.push("startConnection");
388
+ return 101;
389
+ }),
390
+ markConnectSent: vi.fn(() => calls.push("markConnectSent")),
391
+ markConnectionReady: vi.fn(() => calls.push("markConnectionReady")),
392
+ finishConnection: vi.fn((_id, input) => calls.push(`finishConnection:${input.state}`)),
393
+ };
394
+
395
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
396
+ const run = startOpenclawClawlingGateway({
397
+ cfg: {},
398
+ account: baseAccount(),
399
+ abortSignal: abortController.signal,
400
+ setStatus: () => {},
401
+ getStatus: () => ({ connected: false, configured: true, running: true }),
402
+ log: { info: vi.fn(), error: vi.fn() },
403
+ transport,
404
+ store,
405
+ });
406
+
407
+ await Promise.resolve();
408
+ transport.emitInbound(
409
+ JSON.stringify({
410
+ version: "2",
411
+ event: "connect.challenge",
412
+ trace_id: "challenge-1",
413
+ emitted_at: Date.now(),
414
+ payload: { nonce: "nonce-1" },
415
+ }),
416
+ );
417
+ const connectFrame = transport.sent
418
+ .map((raw) => JSON.parse(raw))
419
+ .find((env) => env.event === "connect");
420
+ transport.emitInbound(
421
+ JSON.stringify({
422
+ version: "2",
423
+ event: "hello-ok",
424
+ trace_id: connectFrame.trace_id,
425
+ emitted_at: Date.now(),
426
+ payload: {},
427
+ }),
428
+ );
429
+ await Promise.resolve();
430
+
431
+ abortController.abort();
432
+ await run;
433
+
434
+ expect(calls).toEqual([
435
+ "startConnection",
436
+ "markConnectSent",
437
+ "markConnectionReady",
438
+ "finishConnection:disconnected",
439
+ ]);
440
+ expect(store.startConnection).toHaveBeenCalledWith(
441
+ expect.objectContaining({
442
+ platform: "openclaw",
443
+ accountId: "default",
444
+ attempt: 1,
445
+ reconnectCount: 0,
446
+ }),
447
+ );
448
+ expect(store.markConnectSent).toHaveBeenCalledWith(101);
449
+ expect(store.markConnectionReady).toHaveBeenCalledWith(101);
450
+ expect(store.finishConnection).toHaveBeenCalledWith(
451
+ 101,
452
+ expect.objectContaining({ state: "disconnected", closeCode: 1000 }),
453
+ );
454
+ });
455
+
456
+ it("records hello-ok device metadata when marking a connection ready", async () => {
457
+ const transport = new MockTransport();
458
+ const abortController = new AbortController();
459
+ const store = {
460
+ startConnection: vi.fn(() => 111),
461
+ markConnectSent: vi.fn(),
462
+ markConnectionReady: vi.fn(),
463
+ finishConnection: vi.fn(),
464
+ };
465
+
466
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
467
+ const run = startOpenclawClawlingGateway({
468
+ cfg: {},
469
+ account: baseAccount(),
470
+ abortSignal: abortController.signal,
471
+ setStatus: () => {},
472
+ getStatus: () => ({ connected: false, configured: true, running: true }),
473
+ log: { info: vi.fn(), error: vi.fn() },
474
+ transport,
475
+ store,
476
+ });
477
+
478
+ await Promise.resolve();
479
+ transport.emitInbound(
480
+ JSON.stringify({
481
+ version: "2",
482
+ event: "connect.challenge",
483
+ trace_id: "challenge-1",
484
+ emitted_at: Date.now(),
485
+ payload: { nonce: "nonce-1" },
486
+ }),
487
+ );
488
+ const connectFrame = transport.sent
489
+ .map((raw) => JSON.parse(raw))
490
+ .find((env) => env.event === "connect");
491
+ transport.emitInbound(
492
+ JSON.stringify({
493
+ version: "2",
494
+ event: "hello-ok",
495
+ trace_id: connectFrame.trace_id,
496
+ emitted_at: Date.now(),
497
+ payload: { device_id: "device-resolved", delivery_mode: "device_replay" },
498
+ }),
499
+ );
500
+ await Promise.resolve();
501
+
502
+ abortController.abort();
503
+ await run;
504
+
505
+ expect(store.markConnectionReady).toHaveBeenCalledWith(
506
+ 111,
507
+ expect.objectContaining({
508
+ resolvedDeviceId: "device-resolved",
509
+ deliveryMode: "device_replay",
510
+ }),
511
+ );
512
+ });
513
+
514
+ it("refreshes metadata invalidations without dispatching an agent turn", async () => {
515
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
516
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
517
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
518
+ jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-1") } }),
519
+ );
520
+ const store = {
521
+ startConnection: vi.fn(() => 121),
522
+ markConnectSent: vi.fn(),
523
+ markConnectionReady: vi.fn(),
524
+ finishConnection: vi.fn(),
525
+ getCachedConversation: vi.fn(() => ({
526
+ conversationId: "group-1",
527
+ conversationType: "group",
528
+ metadataVersion: 4,
529
+ lastSeenAt: 1,
530
+ lastRefreshedAt: 1,
531
+ })),
532
+ upsertConversationDetails: vi.fn(),
533
+ deleteConversationCache: vi.fn(),
534
+ };
535
+ const transport = new MockTransport();
536
+ const abortController = new AbortController();
537
+
538
+ try {
539
+ setOpenclawClawlingRuntime(runtime);
540
+ const run = startOpenclawClawlingGateway({
541
+ cfg: {},
542
+ account: baseAccount(),
543
+ abortSignal: abortController.signal,
544
+ setStatus: vi.fn(),
545
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
546
+ log: { info: vi.fn(), error: vi.fn() },
547
+ transport,
548
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
549
+ });
550
+
551
+ await completeHandshake(transport, "challenge-meta-refresh");
552
+ transport.emitInbound(JSON.stringify({
553
+ version: "2",
554
+ event: "chat.metadata.invalidated",
555
+ trace_id: "meta-refresh",
556
+ emitted_at: Date.now(),
557
+ chat_id: "group-1",
558
+ chat_type: "group",
559
+ payload: { scope: ["unknown"], version: 7 },
560
+ }));
561
+ await new Promise((resolve) => setTimeout(resolve, 10));
562
+
563
+ expect(fetchMock).toHaveBeenCalledWith(
564
+ "https://api.example.com/v1/conversations/group-1",
565
+ expect.objectContaining({ method: "GET" }),
566
+ );
567
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
568
+ platform: "openclaw",
569
+ accountId: "default",
570
+ conversationId: "group-1",
571
+ conversationType: "group",
572
+ metadataVersion: 7,
573
+ groupProfile: expect.objectContaining({ title: "Room group-1", metadataVersion: 7 }),
574
+ userProfiles: [expect.objectContaining({ userId: "user-owner", nickname: "Owner" })],
575
+ members: [expect.objectContaining({ userId: "user-owner", role: "owner" })],
576
+ membersComplete: true,
577
+ }));
578
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
579
+
580
+ abortController.abort();
581
+ await run;
582
+ } finally {
583
+ fetchMock.mockRestore();
584
+ }
585
+ });
586
+
587
+ it("logs metadata invalidations without chat_id and skips stale versions", async () => {
588
+ const logs: string[] = [];
589
+ const runtime = buildNoDispatchRuntime();
590
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
591
+ jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-stale") } }),
592
+ );
593
+ const store = {
594
+ startConnection: vi.fn(() => 122),
595
+ markConnectSent: vi.fn(),
596
+ markConnectionReady: vi.fn(),
597
+ finishConnection: vi.fn(),
598
+ getCachedConversation: vi.fn(() => ({
599
+ conversationId: "group-stale",
600
+ conversationType: "group",
601
+ metadataVersion: 9,
602
+ lastSeenAt: 1,
603
+ lastRefreshedAt: 1,
604
+ })),
605
+ upsertConversationDetails: vi.fn(),
606
+ };
607
+ const transport = new MockTransport();
608
+ const abortController = new AbortController();
609
+
610
+ try {
611
+ setOpenclawClawlingRuntime(runtime);
612
+ const run = startOpenclawClawlingGateway({
613
+ cfg: {},
614
+ account: baseAccount(),
615
+ abortSignal: abortController.signal,
616
+ setStatus: vi.fn(),
617
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
618
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
619
+ transport,
620
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
621
+ });
622
+
623
+ await completeHandshake(transport, "challenge-meta-stale");
624
+ transport.emitInbound(JSON.stringify({
625
+ version: "2",
626
+ event: "chat.metadata.invalidated",
627
+ trace_id: "meta-missing-chat",
628
+ emitted_at: Date.now(),
629
+ payload: { version: 10 },
630
+ }));
631
+ transport.emitInbound(JSON.stringify({
632
+ version: "2",
633
+ event: "chat.metadata.invalidated",
634
+ trace_id: "meta-stale",
635
+ emitted_at: Date.now(),
636
+ chat_id: "group-stale",
637
+ payload: { version: 9 },
638
+ }));
639
+ await new Promise((resolve) => setTimeout(resolve, 10));
640
+
641
+ expect(fetchMock).not.toHaveBeenCalled();
642
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
643
+ expect(logs.some((line) => line.includes("metadata invalidation missing chat_id"))).toBe(true);
644
+
645
+ abortController.abort();
646
+ await run;
647
+ } finally {
648
+ fetchMock.mockRestore();
649
+ }
650
+ });
651
+
652
+ it("refreshes metadata invalidations without a version and deletes scoped cache on not found", async () => {
653
+ const runtime = buildNoDispatchRuntime();
654
+ const fetchMock = vi.spyOn(globalThis, "fetch")
655
+ .mockResolvedValueOnce(jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-no-version") } }))
656
+ .mockResolvedValueOnce(jsonEnvelope({ code: 404, msg: "conversation not found", data: {} }));
657
+ const store = {
658
+ startConnection: vi.fn(() => 123),
659
+ markConnectSent: vi.fn(),
660
+ markConnectionReady: vi.fn(),
661
+ finishConnection: vi.fn(),
662
+ getCachedConversation: vi.fn(() => ({
663
+ conversationId: "group-no-version",
664
+ conversationType: "group",
665
+ metadataVersion: 99,
666
+ lastSeenAt: 1,
667
+ lastRefreshedAt: 1,
668
+ })),
669
+ upsertConversationDetails: vi.fn(),
670
+ deleteConversationCache: vi.fn(),
671
+ };
672
+ const transport = new MockTransport();
673
+ const abortController = new AbortController();
674
+
675
+ try {
676
+ setOpenclawClawlingRuntime(runtime);
677
+ const run = startOpenclawClawlingGateway({
678
+ cfg: {},
679
+ account: baseAccount(),
680
+ abortSignal: abortController.signal,
681
+ setStatus: vi.fn(),
682
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
683
+ log: { info: vi.fn(), error: vi.fn() },
684
+ transport,
685
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
686
+ });
687
+
688
+ await completeHandshake(transport, "challenge-meta-noversion");
689
+ transport.emitInbound(JSON.stringify({
690
+ version: "2",
691
+ event: "chat.metadata.invalidated",
692
+ trace_id: "meta-no-version",
693
+ emitted_at: Date.now(),
694
+ chat_id: "group-no-version",
695
+ payload: {},
696
+ }));
697
+ transport.emitInbound(JSON.stringify({
698
+ version: "2",
699
+ event: "chat.metadata.invalidated",
700
+ trace_id: "meta-not-found",
701
+ emitted_at: Date.now(),
702
+ chat_id: "group-missing",
703
+ payload: { version: 100 },
704
+ }));
705
+ await new Promise((resolve) => setTimeout(resolve, 10));
706
+
707
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
708
+ conversationId: "group-no-version",
709
+ }));
710
+ expect(store.deleteConversationCache).toHaveBeenCalledWith({
711
+ platform: "openclaw",
712
+ accountId: "default",
713
+ conversationId: "group-missing",
714
+ });
715
+
716
+ abortController.abort();
717
+ await run;
718
+ } finally {
719
+ fetchMock.mockRestore();
720
+ }
721
+ });
722
+
723
+ it("logs metadata refresh errors without advancing the cached version", async () => {
724
+ const logs: string[] = [];
725
+ const runtime = buildNoDispatchRuntime();
726
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
727
+ new Response("gateway unavailable", { status: 500 }),
728
+ );
729
+ const store = {
730
+ startConnection: vi.fn(() => 124),
731
+ markConnectSent: vi.fn(),
732
+ markConnectionReady: vi.fn(),
733
+ finishConnection: vi.fn(),
734
+ getCachedConversation: vi.fn(() => ({
735
+ conversationId: "group-error",
736
+ conversationType: "group",
737
+ metadataVersion: 1,
738
+ lastSeenAt: 1,
739
+ lastRefreshedAt: 1,
740
+ })),
741
+ upsertConversationDetails: vi.fn(),
742
+ deleteConversationCache: vi.fn(),
743
+ };
744
+ const transport = new MockTransport();
745
+ const abortController = new AbortController();
746
+
747
+ try {
748
+ setOpenclawClawlingRuntime(runtime);
749
+ const run = startOpenclawClawlingGateway({
750
+ cfg: {},
751
+ account: baseAccount(),
752
+ abortSignal: abortController.signal,
753
+ setStatus: vi.fn(),
754
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
755
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
756
+ transport,
757
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
758
+ });
759
+
760
+ await completeHandshake(transport, "challenge-meta-error");
761
+ transport.emitInbound(JSON.stringify({
762
+ version: "2",
763
+ event: "chat.metadata.invalidated",
764
+ trace_id: "meta-error",
765
+ emitted_at: Date.now(),
766
+ chat_id: "group-error",
767
+ payload: { version: 2 },
768
+ }));
769
+ await new Promise((resolve) => setTimeout(resolve, 10));
770
+
771
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
772
+ expect(store.deleteConversationCache).not.toHaveBeenCalled();
773
+ expect(logs.some((line) => line.includes("metadata refresh failed"))).toBe(true);
774
+
775
+ abortController.abort();
776
+ await run;
777
+ } finally {
778
+ fetchMock.mockRestore();
779
+ }
780
+ });
781
+
782
+ it("refreshes activation and cached conversations after hello-ok without writing tool calls", async () => {
783
+ const runtime = buildNoDispatchRuntime();
784
+ const requestedIds: string[] = [];
785
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
786
+ const id = String(input).split("/").at(-1)!;
787
+ requestedIds.push(id);
788
+ if (id === "cached-fail") {
789
+ return new Response("oops", { status: 500 });
790
+ }
791
+ return jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails(id) } });
792
+ });
793
+ const cachedIds = ["cached-1", "activation-1", "cached-fail", ...Array.from({ length: 25 }, (_, i) => `cached-${i + 2}`)];
794
+ const store = {
795
+ startConnection: vi.fn(() => 125),
796
+ markConnectSent: vi.fn(),
797
+ markConnectionReady: vi.fn(),
798
+ finishConnection: vi.fn(),
799
+ getActivationConversation: vi.fn(() => ({
800
+ conversationId: "activation-1",
801
+ conversationType: "direct",
802
+ metadataVersion: null,
803
+ lastSeenAt: null,
804
+ lastRefreshedAt: null,
805
+ })),
806
+ listCachedConversationIds: vi.fn(() => cachedIds),
807
+ upsertConversationDetails: vi.fn(),
808
+ deleteConversationCache: vi.fn(),
809
+ recordToolCall: vi.fn(),
810
+ };
811
+ const transport = new MockTransport();
812
+ const abortController = new AbortController();
813
+
814
+ try {
815
+ setOpenclawClawlingRuntime(runtime);
816
+ const run = startOpenclawClawlingGateway({
817
+ cfg: {},
818
+ account: baseAccount(),
819
+ abortSignal: abortController.signal,
820
+ setStatus: vi.fn(),
821
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
822
+ log: { info: vi.fn(), error: vi.fn() },
823
+ transport,
824
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
825
+ });
826
+
827
+ await completeHandshake(transport, "challenge-fresh-fetch");
828
+ await new Promise((resolve) => setTimeout(resolve, 30));
829
+
830
+ expect(store.listCachedConversationIds).toHaveBeenCalledWith({
831
+ platform: "openclaw",
832
+ accountId: "default",
833
+ limit: 20,
834
+ });
835
+ expect(requestedIds[0]).toBe("activation-1");
836
+ expect(requestedIds.filter((id) => id === "activation-1")).toHaveLength(1);
837
+ expect(requestedIds).toContain("cached-1");
838
+ expect(requestedIds).toContain("cached-fail");
839
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
840
+ conversationId: "cached-1",
841
+ }));
842
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
843
+ conversationId: "cached-2",
844
+ }));
845
+ expect(store.recordToolCall).not.toHaveBeenCalled();
846
+
847
+ abortController.abort();
848
+ await run;
849
+ } finally {
850
+ fetchMock.mockRestore();
851
+ }
852
+ });
853
+
854
+ it("records auth failure and transport error as terminal connection states", async () => {
855
+ const authTransport = new MockTransport();
856
+ const authStore = {
857
+ startConnection: vi.fn(() => 201),
858
+ markConnectSent: vi.fn(),
859
+ markConnectionReady: vi.fn(),
860
+ finishConnection: vi.fn(),
861
+ };
862
+
863
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
864
+ const authRun = startOpenclawClawlingGateway({
865
+ cfg: {},
866
+ account: baseAccount(),
867
+ abortSignal: new AbortController().signal,
868
+ setStatus: () => {},
869
+ getStatus: () => ({ connected: false, configured: true, running: true }),
870
+ log: { info: vi.fn(), error: vi.fn() },
871
+ transport: authTransport,
872
+ store: authStore,
873
+ });
874
+
875
+ await Promise.resolve();
876
+ authTransport.emitInbound(
877
+ JSON.stringify({
878
+ version: "2",
879
+ event: "connect.challenge",
880
+ trace_id: "challenge-auth",
881
+ emitted_at: Date.now(),
882
+ payload: { nonce: "nonce-1" },
883
+ }),
884
+ );
885
+ const authConnectFrame = authTransport.sent
886
+ .map((raw) => JSON.parse(raw))
887
+ .find((env) => env.event === "connect");
888
+ authTransport.emitInbound(
889
+ JSON.stringify({
890
+ version: "2",
891
+ event: "hello-fail",
892
+ trace_id: authConnectFrame.trace_id,
893
+ emitted_at: Date.now(),
894
+ payload: { reason: "authentication failed" },
895
+ }),
896
+ );
897
+
898
+ await authRun;
899
+
900
+ expect(authStore.finishConnection).toHaveBeenCalledWith(
901
+ 201,
902
+ expect.objectContaining({ state: "auth_failed", error: "authentication failed" }),
903
+ );
904
+
905
+ const transport = new MockTransport();
906
+ const abortController = new AbortController();
907
+ const transportStore = {
908
+ startConnection: vi.fn(() => 301),
909
+ markConnectSent: vi.fn(),
910
+ markConnectionReady: vi.fn(),
911
+ finishConnection: vi.fn(),
912
+ };
913
+ const transportRun = startOpenclawClawlingGateway({
914
+ cfg: {},
915
+ account: baseAccount(),
916
+ abortSignal: abortController.signal,
917
+ setStatus: () => {},
918
+ getStatus: () => ({ connected: false, configured: true, running: true }),
919
+ log: { info: vi.fn(), error: vi.fn() },
920
+ transport,
921
+ store: transportStore,
922
+ });
923
+
924
+ await Promise.resolve();
925
+ transport.emitInbound(
926
+ JSON.stringify({
927
+ version: "2",
928
+ event: "connect.challenge",
929
+ trace_id: "challenge-transport",
930
+ emitted_at: Date.now(),
931
+ payload: { nonce: "nonce-1" },
932
+ }),
933
+ );
934
+ const transportConnectFrame = transport.sent
935
+ .map((raw) => JSON.parse(raw))
936
+ .find((env) => env.event === "connect");
937
+ transport.emitInbound(
938
+ JSON.stringify({
939
+ version: "2",
940
+ event: "hello-ok",
941
+ trace_id: transportConnectFrame.trace_id,
942
+ emitted_at: Date.now(),
943
+ payload: {},
944
+ }),
945
+ );
946
+ await Promise.resolve();
947
+ transport.emitError(new Error("socket down"));
948
+ await Promise.resolve();
949
+ abortController.abort();
950
+ await transportRun;
951
+
952
+ expect(transportStore.finishConnection).toHaveBeenCalledWith(
953
+ 301,
954
+ expect.objectContaining({ state: "transport_error", error: "socket down" }),
955
+ );
956
+ });
957
+
958
+ it("logs handshake_ok with the connect trace", async () => {
959
+ const logs: string[] = [];
960
+ const transport = new MockTransport();
961
+ const abortController = new AbortController();
962
+
963
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
964
+ const run = startOpenclawClawlingGateway({
965
+ cfg: {},
966
+ account: baseAccount(),
967
+ abortSignal: abortController.signal,
968
+ setStatus: () => {},
969
+ getStatus: () => ({ connected: false, configured: true, running: true }),
970
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
971
+ transport,
972
+ });
973
+
974
+ await Promise.resolve();
975
+ transport.emitInbound(
976
+ JSON.stringify({
977
+ version: "2",
978
+ event: "connect.challenge",
979
+ trace_id: "challenge-1",
980
+ emitted_at: Date.now(),
981
+ payload: { nonce: "nonce-1" },
982
+ }),
983
+ );
984
+ const connectFrame = transport.sent
985
+ .map((raw) => JSON.parse(raw))
986
+ .find((env) => env.event === "connect");
987
+ transport.emitInbound(
988
+ JSON.stringify({
989
+ version: "2",
990
+ event: "hello-ok",
991
+ trace_id: connectFrame.trace_id,
992
+ emitted_at: Date.now(),
993
+ payload: {},
994
+ }),
995
+ );
996
+ await Promise.resolve();
997
+
998
+ expect(logs).toContainEqual(
999
+ expect.stringMatching(
1000
+ new RegExp(
1001
+ "^clawchat\\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=0 state=ready action=flush_queue trace_id=" +
1002
+ connectFrame.trace_id +
1003
+ " elapsed_ms=\\d+ queue_size=0$",
1004
+ ),
1005
+ ),
1006
+ );
1007
+
1008
+ abortController.abort();
1009
+ await run;
1010
+ });
1011
+
1012
+ it("logs JSON ping and pong as protocol control", async () => {
1013
+ const logs: string[] = [];
1014
+ const transport = new MockTransport();
1015
+ const abortController = new AbortController();
1016
+
1017
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
1018
+ const run = startOpenclawClawlingGateway({
1019
+ cfg: {},
1020
+ account: baseAccount(),
1021
+ abortSignal: abortController.signal,
1022
+ setStatus: () => {},
1023
+ getStatus: () => ({ connected: false, configured: true, running: true }),
1024
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1025
+ transport,
1026
+ });
1027
+
1028
+ await Promise.resolve();
1029
+ transport.emitInbound(
1030
+ JSON.stringify({
1031
+ version: "2",
1032
+ event: "connect.challenge",
1033
+ trace_id: "challenge-1",
1034
+ emitted_at: Date.now(),
1035
+ payload: { nonce: "nonce-1" },
1036
+ }),
1037
+ );
1038
+ const connectFrame = transport.sent
1039
+ .map((raw) => JSON.parse(raw))
1040
+ .find((env) => env.event === "connect");
1041
+ transport.emitInbound(
1042
+ JSON.stringify({
1043
+ version: "2",
1044
+ event: "hello-ok",
1045
+ trace_id: connectFrame.trace_id,
1046
+ emitted_at: Date.now(),
1047
+ payload: {},
1048
+ }),
1049
+ );
1050
+ await Promise.resolve();
1051
+ transport.sent.length = 0;
1052
+
1053
+ transport.emitInbound(
1054
+ JSON.stringify({
1055
+ version: "2",
1056
+ event: "ping",
1057
+ trace_id: "trace-ping",
1058
+ emitted_at: Date.now(),
1059
+ payload: {},
1060
+ }),
1061
+ );
1062
+ transport.emitInbound(
1063
+ JSON.stringify({
1064
+ version: "2",
1065
+ event: "pong",
1066
+ trace_id: "trace-pong",
1067
+ emitted_at: Date.now(),
1068
+ payload: {},
1069
+ }),
1070
+ );
1071
+
1072
+ expect(transport.sent.map((raw) => JSON.parse(raw))).toContainEqual(
1073
+ expect.objectContaining({ event: "pong", trace_id: "trace-ping" }),
1074
+ );
1075
+ expect(logs).toContain(
1076
+ "clawchat.ws event=protocol_ping_received account_id=default attempt=1 reconnect_count=0 state=ready action=send_pong trace_id=trace-ping",
1077
+ );
1078
+ expect(logs).toContain(
1079
+ "clawchat.ws event=protocol_pong_received account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-pong",
1080
+ );
1081
+
1082
+ abortController.abort();
1083
+ await run;
1084
+ });
1085
+
1086
+ it("logs unknown ready-state events as inbound_ignored", async () => {
1087
+ const logs: string[] = [];
1088
+ const transport = new MockTransport();
1089
+ const abortController = new AbortController();
1090
+
1091
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
1092
+ const run = startOpenclawClawlingGateway({
1093
+ cfg: {},
1094
+ account: baseAccount(),
1095
+ abortSignal: abortController.signal,
1096
+ setStatus: () => {},
1097
+ getStatus: () => ({ connected: false, configured: true, running: true }),
1098
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1099
+ transport,
1100
+ });
1101
+
1102
+ await Promise.resolve();
1103
+ transport.emitInbound(
1104
+ JSON.stringify({
1105
+ version: "2",
1106
+ event: "connect.challenge",
1107
+ trace_id: "challenge-1",
1108
+ emitted_at: Date.now(),
1109
+ payload: { nonce: "nonce-1" },
1110
+ }),
1111
+ );
1112
+ const connectFrame = transport.sent
1113
+ .map((raw) => JSON.parse(raw))
1114
+ .find((env) => env.event === "connect");
1115
+ transport.emitInbound(
1116
+ JSON.stringify({
1117
+ version: "2",
1118
+ event: "hello-ok",
1119
+ trace_id: connectFrame.trace_id,
1120
+ emitted_at: Date.now(),
1121
+ payload: {},
1122
+ }),
1123
+ );
1124
+ await Promise.resolve();
1125
+
1126
+ transport.emitInbound(
1127
+ JSON.stringify({
1128
+ version: "2",
1129
+ event: "custom.event",
1130
+ trace_id: "trace-custom",
1131
+ emitted_at: Date.now(),
1132
+ payload: {},
1133
+ }),
1134
+ );
1135
+
1136
+ expect(logs).toContain(
1137
+ "clawchat.ws event=inbound_ignored account_id=default attempt=1 reconnect_count=0 state=ready action=ignore event_name=custom.event trace_id=trace-custom",
1138
+ );
1139
+
1140
+ abortController.abort();
1141
+ await run;
1142
+ });
1143
+
1144
+ it("auto flushes queued outbound when runtime observes connected", async () => {
1145
+ const logs: string[] = [];
1146
+ const transport = new MockTransport();
1147
+ const abortController = new AbortController();
1148
+ const account = baseAccount({
1149
+ ack: { timeout: 15000, autoResendOnTimeout: false },
1150
+ reconnect: {
1151
+ initialDelay: 1,
1152
+ maxDelay: 1,
1153
+ jitterRatio: 0,
1154
+ maxRetries: Number.POSITIVE_INFINITY,
1155
+ },
1156
+ });
1157
+
1158
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
1159
+ const run = startOpenclawClawlingGateway({
1160
+ cfg: {},
1161
+ account,
1162
+ abortSignal: abortController.signal,
1163
+ setStatus: () => {},
1164
+ getStatus: () => ({ connected: false, configured: true, running: true }),
1165
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1166
+ transport,
1167
+ });
1168
+
1169
+ await Promise.resolve();
1170
+ transport.emitInbound(
1171
+ JSON.stringify({
1172
+ version: "2",
1173
+ event: "connect.challenge",
1174
+ trace_id: "challenge-1",
1175
+ emitted_at: Date.now(),
1176
+ payload: { nonce: "nonce-1" },
1177
+ }),
1178
+ );
1179
+ const connectFrame = transport.sent
1180
+ .map((raw) => JSON.parse(raw))
1181
+ .find((env) => env.event === "connect");
1182
+ transport.emitInbound(
1183
+ JSON.stringify({
1184
+ version: "2",
1185
+ event: "hello-ok",
1186
+ trace_id: connectFrame.trace_id,
1187
+ emitted_at: Date.now(),
1188
+ payload: {},
1189
+ }),
1190
+ );
1191
+ await new Promise((resolve) => setTimeout(resolve, 5));
1192
+
1193
+ const client = getOpenclawClawlingClient("default")!;
1194
+ transport.close(1006, "network lost");
1195
+ await Promise.resolve();
1196
+
1197
+ let sendResult: unknown;
1198
+ const sendPromise = sendOpenclawClawlingText({
1199
+ client,
1200
+ account,
1201
+ to: { chatId: "chat-1", chatType: "direct" },
1202
+ text: "queued while reconnecting",
1203
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1204
+ }).then((result) => {
1205
+ sendResult = result;
1206
+ return result;
1207
+ });
1208
+ await Promise.resolve();
1209
+
1210
+ const sentBeforeReady = transport.sent.length;
1211
+ expect(logs.some((line) => line.includes("event=send_queued"))).toBe(true);
1212
+
1213
+ await new Promise((resolve) => setTimeout(resolve, 5));
1214
+ transport.emitInbound(
1215
+ JSON.stringify({
1216
+ version: "2",
1217
+ event: "connect.challenge",
1218
+ trace_id: "challenge-2",
1219
+ emitted_at: Date.now(),
1220
+ payload: { nonce: "nonce-2" },
1221
+ }),
1222
+ );
1223
+ const secondConnectFrame = transport.sent
1224
+ .map((raw) => JSON.parse(raw))
1225
+ .filter((env) => env.event === "connect")
1226
+ .at(-1);
1227
+ transport.emitInbound(
1228
+ JSON.stringify({
1229
+ version: "2",
1230
+ event: "hello-ok",
1231
+ trace_id: secondConnectFrame.trace_id,
1232
+ emitted_at: Date.now(),
1233
+ payload: {},
1234
+ }),
1235
+ );
1236
+ await Promise.resolve();
1237
+
1238
+ expect(logs).toContainEqual(
1239
+ expect.stringMatching(
1240
+ /^clawchat\.ws event=handshake_ok account_id=default attempt=2 reconnect_count=1 state=ready action=flush_queue trace_id=[^ ]+ elapsed_ms=\d+ queue_size=1$/,
1241
+ ),
1242
+ );
1243
+ expect(transport.sent.length).toBe(sentBeforeReady + 2);
1244
+ const queuedFrame = JSON.parse(transport.sent.at(-1)!);
1245
+ expect(queuedFrame.event).toBe("message.send");
1246
+ expect(logs.some((line) => line.includes("event=send_flush"))).toBe(true);
1247
+
1248
+ transport.emitInbound(
1249
+ JSON.stringify({
1250
+ version: "2",
1251
+ event: "message.ack",
1252
+ trace_id: queuedFrame.trace_id,
1253
+ emitted_at: Date.now(),
1254
+ chat_id: "chat-1",
1255
+ payload: { message_id: "server-1", accepted_at: 1234 },
1256
+ }),
1257
+ );
1258
+ await sendPromise;
1259
+ expect(sendResult).toEqual({ messageId: "server-1", acceptedAt: 1234 });
1260
+
1261
+ abortController.abort();
1262
+ await run;
1263
+ });
1264
+
1265
+ it("uses real attempt and reconnect_count in websocket logs across reconnect", async () => {
1266
+ vi.useFakeTimers();
1267
+ const logs: string[] = [];
1268
+ const transport = new MockTransport();
1269
+ const abortController = new AbortController();
1270
+
1271
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
1272
+ const run = startOpenclawClawlingGateway({
1273
+ cfg: {},
1274
+ account: baseAccount({
1275
+ reconnect: {
1276
+ initialDelay: 1000,
1277
+ maxDelay: 30000,
1278
+ jitterRatio: 0,
1279
+ maxRetries: Number.POSITIVE_INFINITY,
1280
+ },
1281
+ }),
1282
+ abortSignal: abortController.signal,
1283
+ setStatus: () => {},
1284
+ getStatus: () => ({ connected: false, configured: true, running: true }),
1285
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1286
+ transport,
1287
+ });
1288
+
1289
+ await Promise.resolve();
1290
+ transport.emitInbound(
1291
+ JSON.stringify({
1292
+ version: "2",
1293
+ event: "connect.challenge",
1294
+ trace_id: "challenge-1",
1295
+ emitted_at: Date.now(),
1296
+ payload: { nonce: "nonce-1" },
1297
+ }),
1298
+ );
1299
+ const firstConnectFrame = transport.sent
1300
+ .map((raw) => JSON.parse(raw))
1301
+ .find((env) => env.event === "connect");
1302
+ transport.emitInbound(
1303
+ JSON.stringify({
1304
+ version: "2",
1305
+ event: "hello-ok",
1306
+ trace_id: firstConnectFrame.trace_id,
1307
+ emitted_at: Date.now(),
1308
+ payload: {},
1309
+ }),
1310
+ );
1311
+ await Promise.resolve();
1312
+
1313
+ transport.close(1006, "network lost");
1314
+ await Promise.resolve();
1315
+ expect(logs).toContain(
1316
+ "clawchat.ws event=connection_lost account_id=default attempt=1 reconnect_count=0 state=ready action=reconnect code=1006 reason=network lost",
1317
+ );
1318
+ expect(logs).toContain(
1319
+ "clawchat.ws event=reconnect_scheduled account_id=default attempt=1 reconnect_count=1 state=reconnecting action=wait delay_ms=1000 max_delay_ms=30000 reason=connection_lost",
1320
+ );
1321
+
1322
+ await vi.advanceTimersByTimeAsync(1000);
1323
+ await Promise.resolve();
1324
+ transport.emitInbound(
1325
+ JSON.stringify({
1326
+ version: "2",
1327
+ event: "connect.challenge",
1328
+ trace_id: "challenge-2",
1329
+ emitted_at: Date.now(),
1330
+ payload: { nonce: "nonce-2" },
1331
+ }),
1332
+ );
1333
+ const secondConnectFrame = transport.sent
1334
+ .map((raw) => JSON.parse(raw))
1335
+ .filter((env) => env.event === "connect")
1336
+ .at(-1);
1337
+ transport.emitInbound(
1338
+ JSON.stringify({
1339
+ version: "2",
1340
+ event: "hello-ok",
1341
+ trace_id: secondConnectFrame.trace_id,
1342
+ emitted_at: Date.now(),
1343
+ payload: {},
1344
+ }),
1345
+ );
1346
+ await Promise.resolve();
1347
+
1348
+ transport.emitInbound(
1349
+ JSON.stringify({
1350
+ version: "2",
1351
+ event: "custom.event",
1352
+ trace_id: "trace-after-reconnect",
1353
+ emitted_at: Date.now(),
1354
+ payload: {},
1355
+ }),
1356
+ );
1357
+ expect(logs).toContain(
1358
+ "clawchat.ws event=inbound_ignored account_id=default attempt=2 reconnect_count=1 state=ready action=ignore event_name=custom.event trace_id=trace-after-reconnect",
1359
+ );
1360
+
1361
+ await vi.advanceTimersByTimeAsync(5000);
1362
+ expect(logs).toContain(
1363
+ "clawchat.ws event=reconnect_backoff_reset account_id=default attempt=2 reconnect_count=0 state=ready action=reset stable_ms=5000",
1364
+ );
1365
+
1366
+ abortController.abort();
1367
+ await run;
1368
+ vi.useRealTimers();
1369
+ });
1370
+ });
1371
+
1372
+ describe("openclaw-clawchat runtime media ingest", () => {
1373
+ it("claims complete inbound messages but not streaming created/add fragments", async () => {
1374
+ const runtime = {
1375
+ channel: {
1376
+ routing: {
1377
+ resolveAgentRoute: vi.fn(() => ({
1378
+ agentId: "u",
1379
+ accountId: "default",
1380
+ sessionKey: "s",
1381
+ })),
1382
+ },
1383
+ session: {
1384
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
1385
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
1386
+ },
1387
+ reply: {
1388
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
1389
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
1390
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
1391
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1392
+ createReplyDispatcherWithTyping: vi.fn(() => ({
1393
+ dispatcher: {},
1394
+ replyOptions: {},
1395
+ markDispatchIdle: vi.fn(),
1396
+ })),
1397
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
1398
+ await opts.run();
1399
+ }),
1400
+ dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
1401
+ },
1402
+ turn: {
1403
+ buildContext: vi.fn((params) =>
1404
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
1405
+ ),
1406
+ },
1407
+ media: {
1408
+ fetchRemoteMedia: vi.fn(),
1409
+ saveMediaBuffer: vi.fn(),
1410
+ loadWebMedia: vi.fn(),
1411
+ },
1412
+ },
1413
+ } as unknown as PluginRuntime;
1414
+ const store = {
1415
+ startConnection: vi.fn(() => 401),
1416
+ markConnectSent: vi.fn(),
1417
+ markConnectionReady: vi.fn(),
1418
+ finishConnection: vi.fn(),
1419
+ claimMessageOnce: vi.fn(() => true),
1420
+ insertMessage: vi.fn(),
1421
+ upsertConversationSummary: vi.fn(),
1422
+ upsertConversationDetails: vi.fn(),
1423
+ };
1424
+ setOpenclawClawlingRuntime(runtime);
1425
+ const transport = new MockTransport();
1426
+ const abortController = new AbortController();
1427
+ const run = startOpenclawClawlingGateway({
1428
+ cfg: {},
1429
+ account: baseAccount(),
1430
+ abortSignal: abortController.signal,
1431
+ setStatus: vi.fn(),
1432
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1433
+ log: { info: vi.fn(), error: vi.fn() },
1434
+ transport,
1435
+ store,
1436
+ });
1437
+
1438
+ await Promise.resolve();
1439
+ transport.emitInbound(
1440
+ JSON.stringify({
1441
+ version: "2",
1442
+ event: "connect.challenge",
1443
+ trace_id: "challenge-inbound-persist",
1444
+ emitted_at: Date.now(),
1445
+ payload: { nonce: "nonce" },
1446
+ }),
1447
+ );
1448
+ const connectFrame = transport.sent
1449
+ .map((raw) => JSON.parse(raw))
1450
+ .find((env) => env.event === "connect");
1451
+ transport.emitInbound(
1452
+ JSON.stringify({
1453
+ version: "2",
1454
+ event: "hello-ok",
1455
+ trace_id: connectFrame.trace_id,
1456
+ emitted_at: Date.now(),
1457
+ payload: {},
1458
+ }),
1459
+ );
1460
+ await Promise.resolve();
1461
+
1462
+ for (const event of ["message.created", "message.add"]) {
1463
+ transport.emitInbound(
1464
+ JSON.stringify({
1465
+ version: "2",
1466
+ event,
1467
+ trace_id: `trace-${event}`,
1468
+ emitted_at: Date.now(),
1469
+ chat_id: "chat-1",
1470
+ chat_type: "direct",
1471
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
1472
+ payload: { message_id: "stream-fragment", fragments: [{ kind: "text", text: "part" }] },
1473
+ }),
1474
+ );
1475
+ }
1476
+
1477
+ transport.emitInbound(
1478
+ JSON.stringify({
1479
+ version: "2",
1480
+ event: "message.send",
1481
+ trace_id: "trace-inbound-complete",
1482
+ emitted_at: 12345,
1483
+ chat_id: "chat-1",
1484
+ chat_type: "direct",
1485
+ to: { id: "u", type: "direct" },
1486
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
1487
+ payload: {
1488
+ message_id: "m-persist-inbound",
1489
+ message_mode: "normal",
1490
+ message: {
1491
+ body: { fragments: [{ kind: "text", text: "hello persisted" }] },
1492
+ context: { mentions: [], reply: null },
1493
+ streaming: {
1494
+ status: "static",
1495
+ sequence: 0,
1496
+ mutation_policy: "sealed",
1497
+ started_at: null,
1498
+ completed_at: null,
1499
+ },
1500
+ },
1501
+ },
1502
+ }),
1503
+ );
1504
+ await new Promise((resolve) => setTimeout(resolve, 10));
1505
+ abortController.abort();
1506
+ await run;
1507
+
1508
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
1509
+ expect(store.upsertConversationSummary).toHaveBeenCalledTimes(1);
1510
+ expect(store.upsertConversationSummary).toHaveBeenCalledWith(expect.objectContaining({
1511
+ platform: "openclaw",
1512
+ accountId: "default",
1513
+ conversationId: "chat-1",
1514
+ conversationType: "direct",
1515
+ lastSeenAt: 12345,
1516
+ }));
1517
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
1518
+ expect(store.claimMessageOnce).toHaveBeenCalledWith({
1519
+ platform: "openclaw",
1520
+ accountId: "default",
1521
+ kind: "message",
1522
+ direction: "inbound",
1523
+ eventType: "message.send",
1524
+ traceId: "trace-inbound-complete",
1525
+ chatId: "chat-1",
1526
+ messageId: "m-persist-inbound",
1527
+ text: "hello persisted",
1528
+ raw: expect.objectContaining({ event: "message.send" }),
1529
+ });
1530
+ expect(store.insertMessage).not.toHaveBeenCalled();
1531
+ });
1532
+
1533
+ it("does not dispatch duplicate inbound messages already claimed in storage", async () => {
1534
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
1535
+ counts: { final: 1, block: 0, tool: 0 },
1536
+ queuedFinal: true,
1537
+ });
1538
+ const claimMessageOnce = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
1539
+ const runtime = {
1540
+ channel: {
1541
+ routing: {
1542
+ resolveAgentRoute: vi.fn(() => ({
1543
+ agentId: "default",
1544
+ accountId: "default",
1545
+ sessionKey: "s",
1546
+ })),
1547
+ },
1548
+ session: {
1549
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
1550
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
1551
+ },
1552
+ reply: {
1553
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
1554
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
1555
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
1556
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1557
+ createReplyDispatcherWithTyping: vi.fn(() => ({
1558
+ dispatcher: {},
1559
+ replyOptions: {},
1560
+ markDispatchIdle: vi.fn(),
1561
+ markRunComplete: vi.fn(),
1562
+ })),
1563
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
1564
+ dispatchReplyFromConfig,
1565
+ },
1566
+ turn: {
1567
+ buildContext: vi.fn((params) =>
1568
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
1569
+ ),
1570
+ },
1571
+ media: {
1572
+ fetchRemoteMedia: vi.fn(),
1573
+ saveMediaBuffer: vi.fn(),
1574
+ loadWebMedia: vi.fn(),
1575
+ },
1576
+ },
1577
+ } as unknown as PluginRuntime;
1578
+ setOpenclawClawlingRuntime(runtime);
1579
+ const transport = new MockTransport();
1580
+ const abortController = new AbortController();
1581
+
1582
+ const run = startOpenclawClawlingGateway({
1583
+ cfg: {},
1584
+ account: baseAccount(),
1585
+ abortSignal: abortController.signal,
1586
+ setStatus: vi.fn(),
1587
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1588
+ log: { info: vi.fn(), error: vi.fn() },
1589
+ transport,
1590
+ store: {
1591
+ startConnection: vi.fn(() => 1),
1592
+ markConnectSent: vi.fn(),
1593
+ markConnectionReady: vi.fn(),
1594
+ finishConnection: vi.fn(),
1595
+ claimMessageOnce,
1596
+ },
1597
+ });
1598
+
1599
+ await Promise.resolve();
1600
+ transport.emitInbound(
1601
+ JSON.stringify({
1602
+ version: "2",
1603
+ event: "connect.challenge",
1604
+ trace_id: "challenge",
1605
+ emitted_at: Date.now(),
1606
+ payload: { nonce: "nonce" },
1607
+ }),
1608
+ );
1609
+ const connectFrame = transport.sent
1610
+ .map((raw) => JSON.parse(raw))
1611
+ .find((env) => env.event === "connect");
1612
+ transport.emitInbound(
1613
+ JSON.stringify({
1614
+ version: "2",
1615
+ event: "hello-ok",
1616
+ trace_id: connectFrame.trace_id,
1617
+ emitted_at: Date.now(),
1618
+ payload: {},
1619
+ }),
1620
+ );
1621
+ await Promise.resolve();
1622
+
1623
+ const duplicateFrame = {
1624
+ version: "2",
1625
+ event: "message.send",
1626
+ trace_id: "dup-trace",
1627
+ emitted_at: Date.now(),
1628
+ chat_id: "chat-1",
1629
+ chat_type: "direct",
1630
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
1631
+ payload: {
1632
+ message_id: "duplicate-message",
1633
+ message_mode: "normal",
1634
+ message: {
1635
+ body: { fragments: [{ kind: "text", text: "hello" }] },
1636
+ context: { mentions: [], reply: null },
1637
+ streaming: {
1638
+ status: "static",
1639
+ sequence: 0,
1640
+ mutation_policy: "sealed",
1641
+ started_at: null,
1642
+ completed_at: null,
1643
+ },
1644
+ },
1645
+ },
1646
+ };
1647
+ transport.emitInbound(JSON.stringify(duplicateFrame));
1648
+ transport.emitInbound(JSON.stringify({ ...duplicateFrame, trace_id: "dup-trace-2" }));
1649
+ await new Promise((resolve) => setTimeout(resolve, 20));
1650
+ abortController.abort();
1651
+ await run;
1652
+
1653
+ expect(claimMessageOnce).toHaveBeenCalledTimes(2);
1654
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1655
+ });
1656
+
1657
+ it("dispatches pending activation bootstrap through the normal direct inbound agent path after ready", async () => {
1658
+ const capturedCtxs: Record<string, unknown>[] = [];
1659
+ const resolveAgentRoute = vi.fn(() => ({
1660
+ agentId: "default",
1661
+ accountId: "default",
1662
+ sessionKey: "session-from-route",
1663
+ }));
1664
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
1665
+ counts: { final: 1, block: 0, tool: 0 },
1666
+ queuedFinal: true,
1667
+ });
1668
+ const runtime = {
1669
+ channel: {
1670
+ routing: { resolveAgentRoute },
1671
+ session: {
1672
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
1673
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
1674
+ },
1675
+ reply: {
1676
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
1677
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
1678
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => {
1679
+ capturedCtxs.push(ctx);
1680
+ return ctx;
1681
+ }),
1682
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1683
+ createReplyDispatcherWithTyping: vi.fn(() => ({
1684
+ dispatcher: {},
1685
+ replyOptions: {},
1686
+ markDispatchIdle: vi.fn(),
1687
+ markRunComplete: vi.fn(),
1688
+ })),
1689
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
1690
+ dispatchReplyFromConfig,
1691
+ },
1692
+ turn: {
1693
+ buildContext: vi.fn((params) => {
1694
+ const ctx = buildTestInboundContext(
1695
+ params as Parameters<typeof buildTestInboundContext>[0],
1696
+ );
1697
+ capturedCtxs.push(ctx);
1698
+ return ctx;
1699
+ }),
1700
+ },
1701
+ media: {
1702
+ fetchRemoteMedia: vi.fn(),
1703
+ saveMediaBuffer: vi.fn(),
1704
+ loadWebMedia: vi.fn(),
1705
+ },
1706
+ },
1707
+ } as unknown as PluginRuntime;
1708
+ const store = {
1709
+ startConnection: vi.fn(() => 501),
1710
+ markConnectSent: vi.fn(),
1711
+ markConnectionReady: vi.fn(),
1712
+ finishConnection: vi.fn(),
1713
+ claimMessageOnce: vi.fn(() => true),
1714
+ claimPendingActivationBootstrap: vi.fn(() => ({ conversationId: "conv-activation" })),
1715
+ markActivationBootstrapSent: vi.fn(() => true),
1716
+ releaseActivationBootstrapClaim: vi.fn(() => true),
1717
+ };
1718
+
1719
+ setOpenclawClawlingRuntime(runtime);
1720
+ const transport = new MockTransport();
1721
+ const abortController = new AbortController();
1722
+ const run = startOpenclawClawlingGateway({
1723
+ cfg: {} as OpenClawConfig,
1724
+ account: baseAccount({ token: "secret-token-value" }),
1725
+ abortSignal: abortController.signal,
1726
+ setStatus: vi.fn(),
1727
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1728
+ log: { info: vi.fn(), error: vi.fn() },
1729
+ transport,
1730
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1731
+ });
1732
+
1733
+ await completeHandshake(transport, "challenge-bootstrap-1");
1734
+ await new Promise((resolve) => setTimeout(resolve, 30));
1735
+ abortController.abort();
1736
+ await run;
1737
+
1738
+ expect(store.claimPendingActivationBootstrap).toHaveBeenCalledWith({
1739
+ platform: "openclaw",
1740
+ accountId: "default",
24
1741
  });
25
- expect(mapClawlingStateToStatus("connecting")).toMatchObject({
26
- connected: false,
27
- running: true,
1742
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(
1743
+ expect.objectContaining({
1744
+ platform: "openclaw",
1745
+ accountId: "default",
1746
+ kind: "message",
1747
+ direction: "inbound",
1748
+ eventType: "message.send",
1749
+ chatId: "conv-activation",
1750
+ messageId: expect.stringContaining("bootstrap"),
1751
+ }),
1752
+ );
1753
+ expect(resolveAgentRoute).toHaveBeenCalledWith(
1754
+ expect.objectContaining({ peer: { kind: "direct", id: "conv-activation" } }),
1755
+ );
1756
+ expect(capturedCtxs).toHaveLength(1);
1757
+ const ctx = capturedCtxs[0]!;
1758
+ const bodyForAgent = String(ctx.BodyForAgent);
1759
+ expect(ctx.From).toBe("openclaw-clawchat:conv-activation");
1760
+ expect(ctx.OriginatingTo).toBe("openclaw-clawchat:conv-activation");
1761
+ expect(ctx.ChatType).toBe("direct");
1762
+ expect(bodyForAgent).toBe(EXPECTED_ACTIVATION_BOOTSTRAP_TEXT);
1763
+ expect(bodyForAgent).not.toContain("secret-token-value");
1764
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1765
+ expect(store.markActivationBootstrapSent).toHaveBeenCalledWith({
1766
+ platform: "openclaw",
1767
+ accountId: "default",
1768
+ conversationId: "conv-activation",
28
1769
  });
1770
+ expect(dispatchReplyFromConfig.mock.invocationCallOrder[0]!).toBeLessThan(
1771
+ store.markActivationBootstrapSent.mock.invocationCallOrder[0]!,
1772
+ );
29
1773
  });
30
1774
 
31
- it("classifies AuthError as fatal/no-retry", () => {
32
- const c = classifyClawlingClientError(new AuthError("hello-fail", "bad-token"));
33
- expect(c.kind).toBe("auth");
34
- expect(c.retry).toBe(false);
35
- });
1775
+ it("does not repeat an activation bootstrap across reconnect while the first dispatch is in flight", async () => {
1776
+ let resolveDispatch: (value: unknown) => void = () => {};
1777
+ const dispatchReplyFromConfig = vi.fn(
1778
+ () =>
1779
+ new Promise((resolve) => {
1780
+ resolveDispatch = resolve;
1781
+ }),
1782
+ );
1783
+ const runtime = {
1784
+ channel: {
1785
+ routing: {
1786
+ resolveAgentRoute: vi.fn(() => ({
1787
+ agentId: "default",
1788
+ accountId: "default",
1789
+ sessionKey: "session-from-route",
1790
+ })),
1791
+ },
1792
+ session: {
1793
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
1794
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
1795
+ },
1796
+ reply: {
1797
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
1798
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
1799
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
1800
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1801
+ createReplyDispatcherWithTyping: vi.fn(() => ({
1802
+ dispatcher: {},
1803
+ replyOptions: {},
1804
+ markDispatchIdle: vi.fn(),
1805
+ markRunComplete: vi.fn(),
1806
+ })),
1807
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
1808
+ dispatchReplyFromConfig,
1809
+ },
1810
+ turn: {
1811
+ buildContext: vi.fn((params) =>
1812
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
1813
+ ),
1814
+ },
1815
+ media: {
1816
+ fetchRemoteMedia: vi.fn(),
1817
+ saveMediaBuffer: vi.fn(),
1818
+ loadWebMedia: vi.fn(),
1819
+ },
1820
+ },
1821
+ } as unknown as PluginRuntime;
1822
+ const store = {
1823
+ startConnection: vi.fn(() => 601),
1824
+ markConnectSent: vi.fn(),
1825
+ markConnectionReady: vi.fn(),
1826
+ finishConnection: vi.fn(),
1827
+ claimMessageOnce: vi.fn(() => true),
1828
+ claimPendingActivationBootstrap: vi
1829
+ .fn()
1830
+ .mockReturnValueOnce({ conversationId: "conv-activation" })
1831
+ .mockReturnValue(null),
1832
+ markActivationBootstrapSent: vi.fn(() => true),
1833
+ releaseActivationBootstrapClaim: vi.fn(() => true),
1834
+ };
1835
+ setOpenclawClawlingRuntime(runtime);
1836
+ const transport = new MockTransport();
1837
+ const abortController = new AbortController();
1838
+ const run = startOpenclawClawlingGateway({
1839
+ cfg: {} as OpenClawConfig,
1840
+ account: baseAccount({
1841
+ reconnect: {
1842
+ initialDelay: 1,
1843
+ maxDelay: 1,
1844
+ jitterRatio: 0,
1845
+ maxRetries: Number.POSITIVE_INFINITY,
1846
+ },
1847
+ }),
1848
+ abortSignal: abortController.signal,
1849
+ setStatus: vi.fn(),
1850
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1851
+ log: { info: vi.fn(), error: vi.fn() },
1852
+ transport,
1853
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1854
+ });
36
1855
 
37
- it("classifies generic errors as unknown", () => {
38
- const c = classifyClawlingClientError(new Error("huh"));
39
- expect(c.kind).toBe("unknown");
40
- expect(c.retry).toBe(false);
1856
+ await completeHandshake(transport, "challenge-bootstrap-first");
1857
+ await new Promise((resolve) => setTimeout(resolve, 10));
1858
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1859
+
1860
+ transport.close(1006, "network lost");
1861
+ await new Promise((resolve) => setTimeout(resolve, 10));
1862
+ await completeHandshake(transport, "challenge-bootstrap-reconnect");
1863
+ await new Promise((resolve) => setTimeout(resolve, 10));
1864
+ expect(store.claimPendingActivationBootstrap).toHaveBeenCalledTimes(2);
1865
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1866
+
1867
+ resolveDispatch({ counts: { final: 1, block: 0, tool: 0 }, queuedFinal: true });
1868
+ await new Promise((resolve) => setTimeout(resolve, 10));
1869
+ abortController.abort();
1870
+ await run;
1871
+
1872
+ expect(store.markActivationBootstrapSent).toHaveBeenCalledTimes(1);
1873
+ expect(store.markActivationBootstrapSent).toHaveBeenCalledWith({
1874
+ platform: "openclaw",
1875
+ accountId: "default",
1876
+ conversationId: "conv-activation",
1877
+ });
41
1878
  });
42
1879
 
43
- it("runtime store round-trips", () => {
44
- const rt = { mocked: true } as unknown as PluginRuntime;
45
- setOpenclawClawlingRuntime(rt);
46
- expect(getOpenclawClawlingRuntime()).toBe(rt);
1880
+ it("releases an activation bootstrap claim when agent submission fails", async () => {
1881
+ const dispatchReplyFromConfig = vi.fn().mockRejectedValue(new Error("dispatch failed"));
1882
+ const runtime = {
1883
+ channel: {
1884
+ routing: {
1885
+ resolveAgentRoute: vi.fn(() => ({
1886
+ agentId: "default",
1887
+ accountId: "default",
1888
+ sessionKey: "session-from-route",
1889
+ })),
1890
+ },
1891
+ session: {
1892
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
1893
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
1894
+ },
1895
+ reply: {
1896
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
1897
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
1898
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
1899
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1900
+ createReplyDispatcherWithTyping: vi.fn(() => ({
1901
+ dispatcher: {},
1902
+ replyOptions: {},
1903
+ markDispatchIdle: vi.fn(),
1904
+ markRunComplete: vi.fn(),
1905
+ })),
1906
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
1907
+ dispatchReplyFromConfig,
1908
+ },
1909
+ turn: {
1910
+ buildContext: vi.fn((params) =>
1911
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
1912
+ ),
1913
+ },
1914
+ media: {
1915
+ fetchRemoteMedia: vi.fn(),
1916
+ saveMediaBuffer: vi.fn(),
1917
+ loadWebMedia: vi.fn(),
1918
+ },
1919
+ },
1920
+ } as unknown as PluginRuntime;
1921
+ const store = {
1922
+ startConnection: vi.fn(() => 701),
1923
+ markConnectSent: vi.fn(),
1924
+ markConnectionReady: vi.fn(),
1925
+ finishConnection: vi.fn(),
1926
+ claimMessageOnce: vi.fn(() => true),
1927
+ claimPendingActivationBootstrap: vi.fn(() => ({ conversationId: "conv-activation" })),
1928
+ markActivationBootstrapSent: vi.fn(() => true),
1929
+ releaseActivationBootstrapClaim: vi.fn(() => true),
1930
+ };
1931
+ setOpenclawClawlingRuntime(runtime);
1932
+ const transport = new MockTransport();
1933
+ const abortController = new AbortController();
1934
+ const run = startOpenclawClawlingGateway({
1935
+ cfg: {} as OpenClawConfig,
1936
+ account: baseAccount(),
1937
+ abortSignal: abortController.signal,
1938
+ setStatus: vi.fn(),
1939
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1940
+ log: { info: vi.fn(), error: vi.fn() },
1941
+ transport,
1942
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1943
+ });
1944
+
1945
+ await completeHandshake(transport, "challenge-bootstrap-failure");
1946
+ await new Promise((resolve) => setTimeout(resolve, 30));
1947
+ abortController.abort();
1948
+ await run;
1949
+
1950
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1951
+ expect(store.markActivationBootstrapSent).not.toHaveBeenCalled();
1952
+ expect(store.releaseActivationBootstrapClaim).toHaveBeenCalledWith({
1953
+ platform: "openclaw",
1954
+ accountId: "default",
1955
+ conversationId: "conv-activation",
1956
+ });
47
1957
  });
48
- });
49
1958
 
50
- describe("openclaw-clawchat runtime media ingest", () => {
51
1959
  it("fetches inbound media via runtime.channel.media and populates MediaPath/MediaPaths", async () => {
52
1960
  const fetched: Array<{ url: string }> = [];
53
1961
  const saved: Array<{ ct: string | undefined }> = [];
54
1962
  let capturedCtx: Record<string, unknown> | undefined;
55
- const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
56
- capturedCtx = ctx;
57
- return ctx;
1963
+ const buildContext = vi.fn((params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) => {
1964
+ capturedCtx = buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
1965
+ return capturedCtx as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>;
58
1966
  });
59
1967
  const resolveAgentRoute = vi.fn(() => ({
60
1968
  agentId: "u",
@@ -77,7 +1985,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
77
1985
  reply: {
78
1986
  formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
79
1987
  resolveEnvelopeFormatOptions: vi.fn(() => ({})),
80
- finalizeInboundContext,
1988
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
81
1989
  resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
82
1990
  createReplyDispatcherWithTyping: vi.fn(() => ({
83
1991
  dispatcher: {},
@@ -90,6 +1998,9 @@ describe("openclaw-clawchat runtime media ingest", () => {
90
1998
  }),
91
1999
  dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
92
2000
  },
2001
+ turn: {
2002
+ buildContext,
2003
+ },
93
2004
  media: {
94
2005
  fetchRemoteMedia: vi.fn(async ({ url }: { url: string }) => {
95
2006
  fetched.push({ url });
@@ -107,7 +2018,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
107
2018
  setOpenclawClawlingRuntime(runtime);
108
2019
 
109
2020
  const { startOpenclawClawlingGateway } = await import("./runtime.ts");
110
- const { MockTransport } = await import("@newbase-clawchat/sdk");
2021
+ const { MockTransport } = await import("./mock-transport.ts");
111
2022
  const transport = new MockTransport();
112
2023
  const abortController = new AbortController();
113
2024
 
@@ -153,11 +2064,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
153
2064
  payload: { nonce: "n" },
154
2065
  }),
155
2066
  );
2067
+ const connectFrame = transport.sent
2068
+ .map((raw) => JSON.parse(raw))
2069
+ .find((env) => env.event === "connect");
156
2070
  transport.emitInbound(
157
2071
  JSON.stringify({
158
2072
  version: "2",
159
2073
  event: "hello-ok",
160
- trace_id: "th",
2074
+ trace_id: connectFrame.trace_id,
161
2075
  emitted_at: Date.now(),
162
2076
  payload: {},
163
2077
  }),
@@ -173,7 +2087,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
173
2087
  chat_id: "chat-1",
174
2088
  chat_type: "direct",
175
2089
  to: { id: "u", type: "direct" },
176
- sender: { sender_id: "user-1", type: "direct", display_name: "User" },
2090
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
177
2091
  payload: {
178
2092
  message_id: "m-with-image",
179
2093
  message_mode: "normal",
@@ -192,7 +2106,6 @@ describe("openclaw-clawchat runtime media ingest", () => {
192
2106
  started_at: null,
193
2107
  completed_at: null,
194
2108
  },
195
- sender: { sender_id: "user-1", type: "direct", display_name: "User" },
196
2109
  },
197
2110
  },
198
2111
  }),
@@ -205,7 +2118,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
205
2118
  expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
206
2119
  expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
207
2120
  expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
2121
+ expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:chat-1");
208
2122
  expect(capturedCtx?.ConversationLabel).toBe("chat-1");
2123
+ expect(capturedCtx?.GroupSystemPrompt).toBeUndefined();
2124
+ const directBuildContextArg = buildContext.mock.calls[0]?.[0] as
2125
+ | Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
2126
+ | undefined;
2127
+ expect(directBuildContextArg?.conversation.kind).toBe("direct");
2128
+ expect(directBuildContextArg?.supplemental).toBeUndefined();
209
2129
  expect(capturedCtx?.SenderId).toBe("user-1");
210
2130
  expect(resolveAgentRoute).toHaveBeenCalledWith(
211
2131
  expect.objectContaining({
@@ -223,9 +2143,13 @@ describe("openclaw-clawchat runtime media ingest", () => {
223
2143
 
224
2144
  it("uses group chat_id as the canonical conversation identity", async () => {
225
2145
  let capturedCtx: Record<string, unknown> | undefined;
226
- const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
227
- capturedCtx = ctx;
228
- return ctx;
2146
+ const buildContext = vi.fn((params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) => {
2147
+ capturedCtx = buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
2148
+ return capturedCtx as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>;
2149
+ });
2150
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
2151
+ counts: { final: 1, block: 0, tool: 0 },
2152
+ queuedFinal: true,
229
2153
  });
230
2154
 
231
2155
  const runtime = {
@@ -244,7 +2168,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
244
2168
  reply: {
245
2169
  formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
246
2170
  resolveEnvelopeFormatOptions: vi.fn(() => ({})),
247
- finalizeInboundContext,
2171
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
248
2172
  resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
249
2173
  createReplyDispatcherWithTyping: vi.fn(() => ({
250
2174
  dispatcher: {},
@@ -255,7 +2179,10 @@ describe("openclaw-clawchat runtime media ingest", () => {
255
2179
  withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
256
2180
  await opts.run();
257
2181
  }),
258
- dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
2182
+ dispatchReplyFromConfig,
2183
+ },
2184
+ turn: {
2185
+ buildContext,
259
2186
  },
260
2187
  media: {
261
2188
  fetchRemoteMedia: vi.fn(),
@@ -284,8 +2211,10 @@ describe("openclaw-clawchat runtime media ingest", () => {
284
2211
  userId: "u",
285
2212
  replyMode: "static",
286
2213
  groupMode: "all",
2214
+ groups: {},
287
2215
  forwardThinking: true,
288
2216
  forwardToolCalls: false,
2217
+ richInteractions: false,
289
2218
  allowFrom: [],
290
2219
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
291
2220
  reconnect: {
@@ -314,11 +2243,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
314
2243
  payload: { nonce: "n" },
315
2244
  }),
316
2245
  );
2246
+ const connectFrame = transport.sent
2247
+ .map((raw) => JSON.parse(raw))
2248
+ .find((env) => env.event === "connect");
317
2249
  transport.emitInbound(
318
2250
  JSON.stringify({
319
2251
  version: "2",
320
2252
  event: "hello-ok",
321
- trace_id: "th",
2253
+ trace_id: connectFrame.trace_id,
322
2254
  emitted_at: Date.now(),
323
2255
  payload: {},
324
2256
  }),
@@ -334,7 +2266,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
334
2266
  chat_id: "grp-1",
335
2267
  chat_type: "group",
336
2268
  to: { id: "u", type: "group" },
337
- sender: { sender_id: "user-1", type: "group", display_name: "Alice" },
2269
+ sender: { id: "user-1", type: "direct", nick_name: "Alice" },
338
2270
  payload: {
339
2271
  message_id: "m-group",
340
2272
  message_mode: "normal",
@@ -359,9 +2291,143 @@ describe("openclaw-clawchat runtime media ingest", () => {
359
2291
  await startPromise;
360
2292
 
361
2293
  expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
2294
+ expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:group:grp-1");
362
2295
  expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
363
2296
  expect(capturedCtx?.SenderId).toBe("user-1");
364
2297
  expect(capturedCtx?.ChatType).toBe("group");
2298
+ expect(capturedCtx?.GroupSystemPrompt).toBe(EXPECTED_CLAWCHAT_GROUP_SYSTEM_PROMPT);
2299
+ const groupBuildContextArg = buildContext.mock.calls[0]?.[0] as
2300
+ | Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
2301
+ | undefined;
2302
+ expect(groupBuildContextArg?.conversation.kind).toBe("group");
2303
+ expect(groupBuildContextArg?.supplemental?.groupSystemPrompt).toBe(
2304
+ EXPECTED_CLAWCHAT_GROUP_SYSTEM_PROMPT,
2305
+ );
2306
+ expect(dispatchReplyFromConfig).toHaveBeenCalledWith(
2307
+ expect.objectContaining({
2308
+ replyOptions: expect.objectContaining({
2309
+ sourceReplyDeliveryMode: "automatic",
2310
+ }),
2311
+ }),
2312
+ );
2313
+ });
2314
+
2315
+ it("dispatches completed message.done frames to the OpenClaw agent path", async () => {
2316
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
2317
+ counts: { final: 1, block: 0, tool: 0 },
2318
+ queuedFinal: true,
2319
+ });
2320
+ const runtime = {
2321
+ channel: {
2322
+ routing: {
2323
+ resolveAgentRoute: vi.fn(() => ({
2324
+ agentId: "default",
2325
+ accountId: "default",
2326
+ sessionKey: "s",
2327
+ })),
2328
+ },
2329
+ session: {
2330
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
2331
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
2332
+ },
2333
+ reply: {
2334
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
2335
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
2336
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
2337
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
2338
+ createReplyDispatcherWithTyping: vi.fn(() => ({
2339
+ dispatcher: {},
2340
+ replyOptions: {},
2341
+ markDispatchIdle: vi.fn(),
2342
+ markRunComplete: vi.fn(),
2343
+ })),
2344
+ withReplyDispatcher: vi.fn(
2345
+ async (opts: { run: () => Promise<unknown>; onSettled?: () => void | Promise<void> }) => {
2346
+ try {
2347
+ return await opts.run();
2348
+ } finally {
2349
+ await opts.onSettled?.();
2350
+ }
2351
+ },
2352
+ ),
2353
+ dispatchReplyFromConfig,
2354
+ },
2355
+ turn: {
2356
+ buildContext: vi.fn((params) =>
2357
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
2358
+ ),
2359
+ },
2360
+ media: {
2361
+ fetchRemoteMedia: vi.fn(),
2362
+ saveMediaBuffer: vi.fn(),
2363
+ loadWebMedia: vi.fn(),
2364
+ },
2365
+ },
2366
+ } as unknown as PluginRuntime;
2367
+ setOpenclawClawlingRuntime(runtime);
2368
+ const transport = new MockTransport();
2369
+ const abortController = new AbortController();
2370
+
2371
+ const run = startOpenclawClawlingGateway({
2372
+ cfg: {},
2373
+ account: baseAccount(),
2374
+ abortSignal: abortController.signal,
2375
+ setStatus: vi.fn(),
2376
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
2377
+ log: { info: vi.fn(), error: vi.fn() },
2378
+ transport,
2379
+ });
2380
+
2381
+ await Promise.resolve();
2382
+ transport.emitInbound(
2383
+ JSON.stringify({
2384
+ version: "2",
2385
+ event: "connect.challenge",
2386
+ trace_id: "challenge",
2387
+ emitted_at: Date.now(),
2388
+ payload: { nonce: "nonce" },
2389
+ }),
2390
+ );
2391
+ const connectFrame = transport.sent
2392
+ .map((raw) => JSON.parse(raw))
2393
+ .find((env) => env.event === "connect");
2394
+ transport.emitInbound(
2395
+ JSON.stringify({
2396
+ version: "2",
2397
+ event: "hello-ok",
2398
+ trace_id: connectFrame.trace_id,
2399
+ emitted_at: Date.now(),
2400
+ payload: {},
2401
+ }),
2402
+ );
2403
+ await Promise.resolve();
2404
+ transport.emitInbound(
2405
+ JSON.stringify({
2406
+ version: "2",
2407
+ event: "message.done",
2408
+ trace_id: "done-1",
2409
+ emitted_at: Date.now(),
2410
+ chat_id: "chat-1",
2411
+ chat_type: "direct",
2412
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
2413
+ payload: {
2414
+ message_id: "stream-1",
2415
+ fragments: [{ kind: "text", text: "completed stream" }],
2416
+ streaming: {
2417
+ status: "done",
2418
+ sequence: 1,
2419
+ mutation_policy: "append_text_only",
2420
+ started_at: null,
2421
+ completed_at: Date.now(),
2422
+ },
2423
+ },
2424
+ }),
2425
+ );
2426
+ await new Promise((resolve) => setTimeout(resolve, 10));
2427
+ abortController.abort();
2428
+ await run;
2429
+
2430
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
365
2431
  });
366
2432
  });
367
2433
 
@@ -406,6 +2472,11 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
406
2472
  withReplyDispatcher,
407
2473
  dispatchReplyFromConfig,
408
2474
  },
2475
+ turn: {
2476
+ buildContext: vi.fn((params) =>
2477
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
2478
+ ),
2479
+ },
409
2480
  media: {
410
2481
  fetchRemoteMedia: vi.fn(),
411
2482
  saveMediaBuffer: vi.fn(),
@@ -462,11 +2533,14 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
462
2533
  payload: { nonce: "n" },
463
2534
  }),
464
2535
  );
2536
+ const connectFrame = transport.sent
2537
+ .map((raw) => JSON.parse(raw))
2538
+ .find((env) => env.event === "connect");
465
2539
  transport.emitInbound(
466
2540
  JSON.stringify({
467
2541
  version: "2",
468
2542
  event: "hello-ok",
469
- trace_id: "th",
2543
+ trace_id: connectFrame.trace_id,
470
2544
  emitted_at: Date.now(),
471
2545
  payload: {},
472
2546
  }),
@@ -482,7 +2556,7 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
482
2556
  chat_id: "chat-1",
483
2557
  chat_type: "direct",
484
2558
  to: { id: "u", type: "direct" },
485
- sender: { sender_id: "user-1", type: "direct", display_name: "User" },
2559
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
486
2560
  payload: {
487
2561
  message_id: "m-fail",
488
2562
  message_mode: "normal",
@@ -512,6 +2586,8 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
512
2586
  expect(logError).toHaveBeenCalledWith(
513
2587
  expect.stringContaining("openclaw-clawchat dispatch failed msg=m-fail"),
514
2588
  );
2589
+ const sentEvents = transport.sent.map((wire) => JSON.parse(wire) as { event: string });
2590
+ expect(sentEvents.filter((event) => event.event === "message.send")).toEqual([]);
515
2591
  });
516
2592
  });
517
2593
 
@@ -569,11 +2645,14 @@ describe("openclaw-clawchat runtime connect flow", () => {
569
2645
  payload: { nonce: "n1" },
570
2646
  }),
571
2647
  );
2648
+ const connectFrame = transport.sent
2649
+ .map((raw) => JSON.parse(raw))
2650
+ .find((env) => env.event === "connect");
572
2651
  transport.emitInbound(
573
2652
  JSON.stringify({
574
2653
  version: "2",
575
2654
  event: "hello-ok",
576
- trace_id: "t2",
2655
+ trace_id: connectFrame.trace_id,
577
2656
  emitted_at: Date.now(),
578
2657
  payload: {},
579
2658
  }),