@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,8 +1,16 @@
1
- import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
1
+ import { EventEmitter } from "node:events";
2
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
5
- import { sendOpenclawClawlingMedia, sendOpenclawClawlingText } from "./outbound.ts";
5
+ import type { Envelope, MessageAckPayload } from "./protocol-types.ts";
6
+ import { createClawChatClient, type ClawlingChatClient } from "./ws-client.ts";
7
+ import { MockTransport } from "./mock-transport.ts";
8
+ import {
9
+ flushAlignedOutboundQueue,
10
+ getAlignedOutboundQueueSize,
11
+ sendOpenclawClawlingMedia,
12
+ sendOpenclawClawlingText,
13
+ } from "./outbound.ts";
6
14
 
7
15
  function baseAccount(
8
16
  overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
@@ -16,6 +24,7 @@ function baseAccount(
16
24
  baseUrl: "",
17
25
  token: "tk",
18
26
  userId: "agent-1",
27
+ ownerUserId: "owner-1",
19
28
  replyMode: "static",
20
29
  forwardThinking: true,
21
30
  forwardToolCalls: false,
@@ -33,49 +42,120 @@ function baseAccount(
33
42
  };
34
43
  }
35
44
 
36
- function mockClient() {
37
- return {
38
- sendMessage: vi.fn().mockResolvedValue({
39
- version: "2",
40
- event: "message.ack",
41
- trace_id: "trace-ack",
42
- emitted_at: Date.now(),
43
- payload: { message_id: "server-m1", accepted_at: 1234 },
44
- }),
45
- replyMessage: vi.fn().mockResolvedValue({
46
- version: "2",
47
- event: "message.ack",
48
- trace_id: "trace-ack-r",
49
- emitted_at: Date.now(),
50
- payload: { message_id: "server-r1", accepted_at: 5678 },
45
+ type MockOutboundClient = ClawlingChatClient & {
46
+ sent: string[];
47
+ setTransportState: (state: "open" | "closed") => void;
48
+ setState: (state: string | undefined) => void;
49
+ };
50
+
51
+ function mockClient(options: { transportState?: "open" | "closed"; state?: string } = {}): MockOutboundClient {
52
+ let transportState = options.transportState ?? "open";
53
+ let clientState = options.state;
54
+ let trace = 0;
55
+ const sent: string[] = [];
56
+ const client = Object.assign(new EventEmitter(), {
57
+ sent,
58
+ setTransportState: (state: "open" | "closed") => {
59
+ transportState = state;
60
+ },
61
+ setState: (state: string | undefined) => {
62
+ clientState = state;
63
+ },
64
+ nextTraceId: vi.fn(() => `trace-${++trace}`),
65
+ sendWire: vi.fn((wire: string) => {
66
+ if (transportState !== "open") throw new Error("socket closed");
67
+ sent.push(wire);
51
68
  }),
52
69
  typing: vi.fn(),
53
70
  emitRaw: vi.fn(),
54
- } as unknown as ClawlingChatClient;
71
+ sendRawEnvelope: vi.fn(),
72
+ });
73
+ Object.defineProperty(client, "transportState", { get: () => transportState });
74
+ Object.defineProperty(client, "state", { get: () => clientState });
75
+ return client as unknown as MockOutboundClient;
76
+ }
77
+
78
+ function decodeSent(client: MockOutboundClient): Envelope[] {
79
+ return client.sent.map((wire) => JSON.parse(wire) as Envelope);
80
+ }
81
+
82
+ function emitAck(
83
+ client: MockOutboundClient,
84
+ traceId: string,
85
+ payload: MessageAckPayload = { message_id: "server-m1", accepted_at: 1234 },
86
+ ): void {
87
+ client.emit("raw", {
88
+ version: "2",
89
+ event: "message.ack",
90
+ trace_id: traceId,
91
+ emitted_at: Date.now(),
92
+ payload,
93
+ } satisfies Envelope<MessageAckPayload>);
94
+ }
95
+
96
+ function emitMessageError(client: MockOutboundClient, traceId: string): void {
97
+ client.emit("raw", {
98
+ version: "2",
99
+ event: "message.error",
100
+ trace_id: traceId,
101
+ emitted_at: Date.now(),
102
+ chat_id: "missing-chat",
103
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
104
+ } satisfies Envelope);
105
+ }
106
+
107
+ function decodeTransportSent(transport: MockTransport): Envelope[] {
108
+ return transport.sent.map((wire) => JSON.parse(wire) as Envelope);
109
+ }
110
+
111
+ async function connectReady(transport: MockTransport, client: ReturnType<typeof createClawChatClient>) {
112
+ const connected = client.connect();
113
+ await Promise.resolve();
114
+ transport.emitInbound(JSON.stringify({
115
+ version: "2",
116
+ event: "connect.challenge",
117
+ trace_id: "challenge-1",
118
+ emitted_at: Date.now(),
119
+ payload: { nonce: "nonce-1" },
120
+ }));
121
+ const connectFrame = decodeTransportSent(transport).find((env) => env.event === "connect")!;
122
+ transport.emitInbound(JSON.stringify({
123
+ version: "2",
124
+ event: "hello-ok",
125
+ trace_id: connectFrame.trace_id,
126
+ emitted_at: Date.now(),
127
+ payload: { device_id: "agent-1", delivery_mode: "device_replay" },
128
+ }));
129
+ await connected;
55
130
  }
56
131
 
57
132
  describe("openclaw-clawchat outbound", () => {
58
- it("routes to sendMessage when no replyCtx", async () => {
133
+ it("sends message.send when no replyCtx", async () => {
59
134
  const client = mockClient();
60
- const result = await sendOpenclawClawlingText({
135
+ const send = sendOpenclawClawlingText({
61
136
  client,
62
137
  account: baseAccount(),
63
138
  to: { chatId: "user-1", chatType: "direct" },
64
139
  text: "hello",
65
140
  });
66
- expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
141
+ const frame = decodeSent(client)[0]!;
142
+ expect(frame).toMatchObject({
143
+ event: "message.send",
67
144
  chat_id: "user-1",
68
- body: { fragments: [{ kind: "text", text: "hello" }] },
145
+ payload: {
146
+ message: { body: { fragments: [{ kind: "text", text: "hello" }] } },
147
+ },
69
148
  });
70
- expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
71
- expect(client.replyMessage).not.toHaveBeenCalled();
149
+ expect(frame).not.toHaveProperty("chat_type");
150
+ emitAck(client, frame.trace_id);
151
+ const result = await send;
72
152
  expect(result?.messageId).toBe("server-m1");
73
153
  expect(result?.acceptedAt).toBe(1234);
74
154
  });
75
155
 
76
- it("routes to replyMessage when replyCtx is provided", async () => {
156
+ it("sends message.reply when replyCtx is provided", async () => {
77
157
  const client = mockClient();
78
- await sendOpenclawClawlingText({
158
+ const send = sendOpenclawClawlingText({
79
159
  client,
80
160
  account: baseAccount(),
81
161
  to: { chatId: "chat-1", chatType: "direct" },
@@ -88,18 +168,346 @@ describe("openclaw-clawchat outbound", () => {
88
168
  replyPreviewText: "original",
89
169
  },
90
170
  });
91
- expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
171
+ const frame = decodeSent(client)[0]!;
172
+ expect(frame).toMatchObject({
173
+ event: "message.reply",
92
174
  chat_id: "chat-1",
93
- replyTo: {
94
- msgId: "m-orig",
95
- senderId: "chat-1",
96
- nickName: "Sender",
97
- fragments: [{ kind: "text", text: "original" }],
175
+ payload: {
176
+ message: {
177
+ body: { fragments: [{ kind: "text", text: "reply" }] },
178
+ context: {
179
+ reply: {
180
+ reply_to_msg_id: "m-orig",
181
+ reply_preview: {
182
+ id: "user-2",
183
+ nick_name: "Sender",
184
+ fragments: [{ kind: "text", text: "original" }],
185
+ },
186
+ },
187
+ },
188
+ },
98
189
  },
99
- body: { fragments: [{ kind: "text", text: "reply" }] },
100
190
  });
101
- expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
102
- expect(client.sendMessage).not.toHaveBeenCalled();
191
+ expect(frame).not.toHaveProperty("chat_type");
192
+ emitAck(client, frame.trace_id, { message_id: "server-r1", accepted_at: 5678 });
193
+ await expect(send).resolves.toMatchObject({ messageId: "server-r1", acceptedAt: 5678 });
194
+ });
195
+
196
+ it("sends static outbound with caller supplied payload message_id", async () => {
197
+ const client = mockClient();
198
+ const send = sendOpenclawClawlingText({
199
+ client,
200
+ account: baseAccount(),
201
+ to: { chatId: "chat-1", chatType: "direct" },
202
+ text: "hello",
203
+ messageId: "local-msg-1",
204
+ });
205
+ const frame = decodeSent(client)[0]!;
206
+ expect((frame.payload as { message_id?: string }).message_id).toBe("local-msg-1");
207
+ emitAck(client, frame.trace_id, { message_id: "local-msg-1", accepted_at: 1234 });
208
+ await expect(send).resolves.toMatchObject({ messageId: "local-msg-1" });
209
+ });
210
+
211
+ it("rejects static outbound when ack message_id does not match", async () => {
212
+ const client = mockClient();
213
+ const send = sendOpenclawClawlingText({
214
+ client,
215
+ account: baseAccount(),
216
+ to: { chatId: "chat-1", chatType: "direct" },
217
+ text: "hello",
218
+ messageId: "local-msg-1",
219
+ });
220
+ const frame = decodeSent(client)[0]!;
221
+ emitAck(client, frame.trace_id, { message_id: "other-msg", accepted_at: 1234 });
222
+ await expect(send).rejects.toThrow("ack message_id mismatch");
223
+ });
224
+
225
+ it("rejects aligned outbound sends from matching message.error", async () => {
226
+ vi.useFakeTimers();
227
+ try {
228
+ const client = mockClient();
229
+ const send = sendOpenclawClawlingText({
230
+ client,
231
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
232
+ to: { chatId: "missing-chat", chatType: "direct" },
233
+ text: "hello",
234
+ });
235
+ const frame = decodeSent(client)[0]!;
236
+
237
+ emitMessageError(client, frame.trace_id);
238
+
239
+ await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
240
+ await vi.advanceTimersByTimeAsync(1000);
241
+ } finally {
242
+ vi.useRealTimers();
243
+ }
244
+ });
245
+
246
+ it("ignores unmatched message.error without rejecting unrelated aligned sends", async () => {
247
+ vi.useFakeTimers();
248
+ try {
249
+ const client = mockClient();
250
+ const logs: string[] = [];
251
+ const send = sendOpenclawClawlingText({
252
+ client,
253
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
254
+ to: { chatId: "chat-1", chatType: "direct" },
255
+ text: "hello",
256
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
257
+ });
258
+ const frame = decodeSent(client)[0]!;
259
+
260
+ emitMessageError(client, "trace-other");
261
+
262
+ expect(logs).toContain(
263
+ "clawchat.ws event=ack_unmatched account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-other chat_id=missing-chat",
264
+ );
265
+
266
+ await vi.advanceTimersByTimeAsync(999);
267
+ const pendingOutcome = await Promise.race([
268
+ send.then(() => "resolved", (err) => err),
269
+ Promise.resolve("pending"),
270
+ ]);
271
+ expect(pendingOutcome).toBe("pending");
272
+
273
+ emitAck(client, frame.trace_id, { message_id: "server-m1", accepted_at: 1234 });
274
+ await expect(send).resolves.toMatchObject({ messageId: "server-m1", acceptedAt: 1234 });
275
+ } finally {
276
+ vi.useRealTimers();
277
+ }
278
+ });
279
+
280
+ it("rejects only the matching aligned send without per-send unmatched logs", async () => {
281
+ const client = mockClient();
282
+ const logs: string[] = [];
283
+ const log = { info: (msg: string) => logs.push(msg), error: (msg: string) => logs.push(msg) };
284
+ const account = baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } });
285
+
286
+ const first = sendOpenclawClawlingText({
287
+ client,
288
+ account,
289
+ to: { chatId: "missing-chat", chatType: "direct" },
290
+ text: "first",
291
+ log,
292
+ });
293
+ const second = sendOpenclawClawlingText({
294
+ client,
295
+ account,
296
+ to: { chatId: "chat-2", chatType: "direct" },
297
+ text: "second",
298
+ log,
299
+ });
300
+ const [firstFrame, secondFrame] = decodeSent(client);
301
+
302
+ emitMessageError(client, firstFrame!.trace_id);
303
+
304
+ await expect(first).rejects.toThrow(/chat_not_found.*chat not resolvable/);
305
+ expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
306
+
307
+ emitAck(client, secondFrame!.trace_id, { message_id: "server-second", accepted_at: 1234 });
308
+ await expect(second).resolves.toMatchObject({ messageId: "server-second" });
309
+ });
310
+
311
+ it("does not let core websocket warn after aligned outbound handles message.error", async () => {
312
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
313
+ try {
314
+ const transport = new MockTransport();
315
+ const client = createClawChatClient({
316
+ url: "ws://test",
317
+ token: "token-1",
318
+ deviceId: "agent-1",
319
+ transport,
320
+ traceIdFactory: vi.fn()
321
+ .mockReturnValueOnce("trace-connect")
322
+ .mockReturnValueOnce("trace-aligned"),
323
+ ack: { timeout: 1000, autoResendOnTimeout: false },
324
+ heartbeat: { enabled: false },
325
+ });
326
+ await connectReady(transport, client);
327
+
328
+ const send = sendOpenclawClawlingText({
329
+ client,
330
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
331
+ to: { chatId: "missing-chat", chatType: "direct" },
332
+ text: "hello",
333
+ });
334
+ const frame = decodeTransportSent(transport).find((env) => env.event === "message.send")!;
335
+ transport.emitInbound(JSON.stringify({
336
+ version: "2",
337
+ event: "message.error",
338
+ trace_id: frame.trace_id,
339
+ emitted_at: Date.now(),
340
+ chat_id: "missing-chat",
341
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
342
+ }));
343
+
344
+ await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
345
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
346
+ } finally {
347
+ warn.mockRestore();
348
+ }
349
+ });
350
+
351
+ it("logs truly unmatched message.error once when aligned tracker is installed", async () => {
352
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
353
+ try {
354
+ const transport = new MockTransport();
355
+ const client = createClawChatClient({
356
+ url: "ws://test",
357
+ token: "token-1",
358
+ deviceId: "agent-1",
359
+ transport,
360
+ traceIdFactory: vi.fn()
361
+ .mockReturnValueOnce("trace-connect")
362
+ .mockReturnValueOnce("trace-aligned"),
363
+ ack: { timeout: 1000, autoResendOnTimeout: false },
364
+ heartbeat: { enabled: false },
365
+ });
366
+ await connectReady(transport, client);
367
+ const logs: string[] = [];
368
+
369
+ const aligned = sendOpenclawClawlingText({
370
+ client,
371
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
372
+ to: { chatId: "chat-aligned", chatType: "direct" },
373
+ text: "aligned",
374
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
375
+ });
376
+
377
+ transport.emitInbound(JSON.stringify({
378
+ version: "2",
379
+ event: "message.error",
380
+ trace_id: "trace-unmatched",
381
+ emitted_at: Date.now(),
382
+ chat_id: "missing-chat",
383
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
384
+ }));
385
+
386
+ expect(logs.filter((line) => line.includes("event=ack_unmatched"))).toEqual([
387
+ "clawchat.ws event=ack_unmatched account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-unmatched chat_id=missing-chat",
388
+ ]);
389
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
390
+
391
+ transport.emitInbound(JSON.stringify({
392
+ version: "2",
393
+ event: "message.ack",
394
+ trace_id: "trace-aligned",
395
+ emitted_at: Date.now(),
396
+ chat_id: "chat-aligned",
397
+ payload: { message_id: "server-aligned", accepted_at: 1234 },
398
+ }));
399
+ await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
400
+ } finally {
401
+ warn.mockRestore();
402
+ }
403
+ });
404
+
405
+ it("does not swallow truly unmatched message.error when tracker was installed without log sink", async () => {
406
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
407
+ try {
408
+ const transport = new MockTransport();
409
+ const client = createClawChatClient({
410
+ url: "ws://test",
411
+ token: "token-1",
412
+ deviceId: "agent-1",
413
+ transport,
414
+ traceIdFactory: vi.fn()
415
+ .mockReturnValueOnce("trace-connect")
416
+ .mockReturnValueOnce("trace-aligned"),
417
+ ack: { timeout: 1000, autoResendOnTimeout: false },
418
+ heartbeat: { enabled: false },
419
+ });
420
+ await connectReady(transport, client);
421
+
422
+ const aligned = sendOpenclawClawlingText({
423
+ client,
424
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
425
+ to: { chatId: "chat-aligned", chatType: "direct" },
426
+ text: "aligned",
427
+ });
428
+
429
+ transport.emitInbound(JSON.stringify({
430
+ version: "2",
431
+ event: "message.error",
432
+ trace_id: "trace-unmatched",
433
+ emitted_at: Date.now(),
434
+ chat_id: "missing-chat",
435
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
436
+ }));
437
+
438
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
439
+
440
+ transport.emitInbound(JSON.stringify({
441
+ version: "2",
442
+ event: "message.ack",
443
+ trace_id: "trace-aligned",
444
+ emitted_at: Date.now(),
445
+ chat_id: "chat-aligned",
446
+ payload: { message_id: "server-aligned", accepted_at: 1234 },
447
+ }));
448
+ await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
449
+ } finally {
450
+ warn.mockRestore();
451
+ }
452
+ });
453
+
454
+ it("does not log aligned unmatched when message.error matches a core pending send", async () => {
455
+ const transport = new MockTransport();
456
+ const client = createClawChatClient({
457
+ url: "ws://test",
458
+ token: "token-1",
459
+ deviceId: "agent-1",
460
+ transport,
461
+ traceIdFactory: vi.fn()
462
+ .mockReturnValueOnce("trace-connect")
463
+ .mockReturnValueOnce("trace-aligned")
464
+ .mockReturnValueOnce("trace-core"),
465
+ ack: { timeout: 1000, autoResendOnTimeout: false },
466
+ heartbeat: { enabled: false },
467
+ });
468
+ await connectReady(transport, client);
469
+ const logs: string[] = [];
470
+
471
+ const aligned = sendOpenclawClawlingText({
472
+ client,
473
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
474
+ to: { chatId: "chat-aligned", chatType: "direct" },
475
+ text: "aligned",
476
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
477
+ });
478
+ const core = client.sendAckableEnvelope({
479
+ eventName: "message.send",
480
+ chatId: "missing-chat",
481
+ payload: {
482
+ message_mode: "normal",
483
+ message: {
484
+ body: { fragments: [{ kind: "text", text: "core" }] },
485
+ context: { mentions: [], reply: null },
486
+ },
487
+ },
488
+ });
489
+
490
+ transport.emitInbound(JSON.stringify({
491
+ version: "2",
492
+ event: "message.error",
493
+ trace_id: "trace-core",
494
+ emitted_at: Date.now(),
495
+ chat_id: "missing-chat",
496
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
497
+ }));
498
+
499
+ await expect(core).rejects.toThrow(/chat_not_found.*chat not resolvable/);
500
+ expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
501
+
502
+ transport.emitInbound(JSON.stringify({
503
+ version: "2",
504
+ event: "message.ack",
505
+ trace_id: "trace-aligned",
506
+ emitted_at: Date.now(),
507
+ chat_id: "chat-aligned",
508
+ payload: { message_id: "server-aligned", accepted_at: 1234 },
509
+ }));
510
+ await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
103
511
  });
104
512
 
105
513
  it("suppresses send when text is empty after trim", async () => {
@@ -110,60 +518,48 @@ describe("openclaw-clawchat outbound", () => {
110
518
  to: { chatId: "u", chatType: "direct" },
111
519
  text: " ",
112
520
  });
113
- expect(client.sendMessage).not.toHaveBeenCalled();
114
- expect(client.replyMessage).not.toHaveBeenCalled();
521
+ expect(client.sent).toHaveLength(0);
115
522
  expect(result).toBeNull();
116
523
  });
117
524
 
118
- it("propagates SDK errors", async () => {
119
- const client = {
120
- ...mockClient(),
121
- sendMessage: vi.fn().mockRejectedValue(new Error("boom")),
122
- } as unknown as ClawlingChatClient;
123
- await expect(
124
- sendOpenclawClawlingText({
125
- client,
126
- account: baseAccount(),
127
- to: { chatId: "u", chatType: "direct" },
128
- text: "hi",
129
- }),
130
- ).rejects.toThrow("boom");
131
- });
132
-
133
525
  it("appends mediaFragments after text in body.fragments", async () => {
134
526
  const client = mockClient();
135
- await sendOpenclawClawlingText({
527
+ const send = sendOpenclawClawlingText({
136
528
  client,
137
529
  account: baseAccount(),
138
530
  to: { chatId: "user-1", chatType: "direct" },
139
531
  text: "look",
140
532
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
141
533
  });
142
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
143
- expect(callArg.body.fragments).toEqual([
534
+ const frame = decodeSent(client)[0]!;
535
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
144
536
  { kind: "text", text: "look" },
145
537
  { kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
146
538
  ]);
539
+ emitAck(client, frame.trace_id);
540
+ await send;
147
541
  });
148
542
 
149
543
  it("sends media-only message when text empty but mediaFragments present", async () => {
150
544
  const client = mockClient();
151
- const result = await sendOpenclawClawlingText({
545
+ const send = sendOpenclawClawlingText({
152
546
  client,
153
547
  account: baseAccount(),
154
548
  to: { chatId: "user-1", chatType: "direct" },
155
549
  text: "",
156
550
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
157
551
  });
158
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
159
- expect(callArg.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
552
+ const frame = decodeSent(client)[0]!;
553
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
554
+ emitAck(client, frame.trace_id);
555
+ const result = await send;
160
556
  expect(result?.messageId).toBe("server-m1");
161
557
  });
162
558
 
163
- it("downgrades replyCtx + media to sendMessage", async () => {
559
+ it("preserves replyCtx when media fragments are present", async () => {
164
560
  const client = mockClient();
165
561
  const log = { info: vi.fn(), error: vi.fn() };
166
- await sendOpenclawClawlingText({
562
+ const send = sendOpenclawClawlingText({
167
563
  client,
168
564
  account: baseAccount(),
169
565
  to: { chatId: "user-1", chatType: "direct" },
@@ -177,9 +573,25 @@ describe("openclaw-clawchat outbound", () => {
177
573
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
178
574
  log,
179
575
  });
180
- expect(client.replyMessage).not.toHaveBeenCalled();
181
- expect(client.sendMessage).toHaveBeenCalled();
182
- expect(log.info).toHaveBeenCalledWith(
576
+ const frame = decodeSent(client)[0]!;
577
+ const payload = frame.payload as {
578
+ message: {
579
+ body: { fragments: unknown[] };
580
+ context: { reply: { reply_to_msg_id: string; reply_preview: { id: string; nick_name: string } } };
581
+ };
582
+ };
583
+ expect(frame.event).toBe("message.reply");
584
+ expect(payload.message.context.reply).toMatchObject({
585
+ reply_to_msg_id: "m-orig",
586
+ reply_preview: { id: "user-2", nick_name: "Sender" },
587
+ });
588
+ expect(payload.message.body.fragments).toEqual([
589
+ { kind: "text", text: "hi" },
590
+ { kind: "image", url: "https://cdn/x.png" },
591
+ ]);
592
+ emitAck(client, frame.trace_id);
593
+ await send;
594
+ expect(log.info).not.toHaveBeenCalledWith(
183
595
  expect.stringMatching(/replyCtx \+ media: downgraded to sendMessage/),
184
596
  );
185
597
  });
@@ -193,41 +605,45 @@ describe("openclaw-clawchat outbound", () => {
193
605
  text: " ",
194
606
  mediaFragments: [],
195
607
  });
196
- expect(client.sendMessage).not.toHaveBeenCalled();
608
+ expect(client.sent).toHaveLength(0);
197
609
  expect(result).toBeNull();
198
610
  });
199
611
 
200
612
  it("sendOpenclawClawlingMedia with image and caption sends both fragments", async () => {
201
613
  const client = mockClient();
202
- const result = await sendOpenclawClawlingMedia({
614
+ const send = sendOpenclawClawlingMedia({
203
615
  client,
204
616
  account: baseAccount(),
205
617
  to: { chatId: "user-1", chatType: "direct" },
206
618
  text: "look at this",
207
619
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
208
620
  });
209
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
210
- expect(callArg.body.fragments).toEqual([
621
+ const frame = decodeSent(client)[0]!;
622
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
211
623
  { kind: "text", text: "look at this" },
212
624
  { kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
213
625
  ]);
626
+ emitAck(client, frame.trace_id);
627
+ const result = await send;
214
628
  expect(result?.messageId).toBe("server-m1");
215
629
  });
216
630
 
217
631
  it("sendOpenclawClawlingMedia with image only (no text) sends just the media fragment", async () => {
218
632
  const client = mockClient();
219
- const result = await sendOpenclawClawlingMedia({
633
+ const send = sendOpenclawClawlingMedia({
220
634
  client,
221
635
  account: baseAccount(),
222
636
  to: { chatId: "user-1", chatType: "direct" },
223
637
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
224
638
  });
225
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
226
- expect(callArg.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
639
+ const frame = decodeSent(client)[0]!;
640
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
641
+ emitAck(client, frame.trace_id);
642
+ const result = await send;
227
643
  expect(result?.messageId).toBe("server-m1");
228
644
  });
229
645
 
230
- it("sendOpenclawClawlingMedia returns null and does not call SDK when mediaFragments is empty", async () => {
646
+ it("sendOpenclawClawlingMedia returns null and does not send when mediaFragments is empty", async () => {
231
647
  const client = mockClient();
232
648
  const log = { info: vi.fn(), error: vi.fn() };
233
649
  const result = await sendOpenclawClawlingMedia({
@@ -237,11 +653,216 @@ describe("openclaw-clawchat outbound", () => {
237
653
  mediaFragments: [],
238
654
  log,
239
655
  });
240
- expect(client.sendMessage).not.toHaveBeenCalled();
241
- expect(client.replyMessage).not.toHaveBeenCalled();
656
+ expect(client.sent).toHaveLength(0);
242
657
  expect(result).toBeNull();
243
658
  expect(log.info).toHaveBeenCalledWith(
244
659
  expect.stringMatching(/sendMedia called with empty mediaFragments/),
245
660
  );
246
661
  });
662
+
663
+ it("starts ack timeout only after a queued ackable frame is written", async () => {
664
+ vi.useFakeTimers();
665
+ const logs: string[] = [];
666
+ const client = mockClient({ transportState: "closed" });
667
+
668
+ let rejected: unknown;
669
+ const promise = sendOpenclawClawlingText({
670
+ client,
671
+ account: baseAccount({ ack: { timeout: 15000, autoResendOnTimeout: false } }),
672
+ to: { chatId: "chat-1", chatType: "direct" },
673
+ text: "hello",
674
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
675
+ });
676
+ promise.catch((err) => {
677
+ rejected = err;
678
+ });
679
+
680
+ await Promise.resolve();
681
+ await vi.advanceTimersByTimeAsync(15001);
682
+ expect(rejected).toBeUndefined();
683
+ expect(client.sent).toHaveLength(0);
684
+
685
+ client.setTransportState("open");
686
+ flushAlignedOutboundQueue(client);
687
+ expect(client.sent).toHaveLength(1);
688
+
689
+ await vi.advanceTimersByTimeAsync(14999);
690
+ expect(rejected).toBeUndefined();
691
+ await vi.advanceTimersByTimeAsync(1);
692
+
693
+ await expect(promise).rejects.toThrow(/ack timeout/);
694
+ expect(logs).toContain(
695
+ "clawchat.ws event=ack_timeout account_id=default attempt=1 reconnect_count=0 state=ready action=reject_no_reconnect event_name=message.send trace_id=trace-1 chat_id=chat-1 timeout_ms=15000",
696
+ );
697
+ vi.useRealTimers();
698
+ });
699
+
700
+ it("rejects an ackable send when queue overflow drops it before write", async () => {
701
+ const logs: string[] = [];
702
+ const client = mockClient({ transportState: "closed" });
703
+ const account = baseAccount();
704
+
705
+ const first = sendOpenclawClawlingText({
706
+ client,
707
+ account,
708
+ to: { chatId: "chat-1", chatType: "direct" },
709
+ text: "first",
710
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
711
+ });
712
+
713
+ for (let i = 0; i < 128; i += 1) {
714
+ sendOpenclawClawlingText({
715
+ client,
716
+ account,
717
+ to: { chatId: "chat-1", chatType: "direct" },
718
+ text: `queued-${i}`,
719
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
720
+ }).catch(() => {});
721
+ }
722
+
723
+ await Promise.resolve();
724
+
725
+ expect(getAlignedOutboundQueueSize(client)).toBe(128);
726
+ expect(client.listenerCount("close")).toBe(1);
727
+ await expect(first).rejects.toThrow(/send queue full/);
728
+ expect(logs.some((line) => line.includes("event=send_queue_drop"))).toBe(true);
729
+ });
730
+
731
+ it("cancels a queued ackable send on terminal close without later flushing it", async () => {
732
+ const client = mockClient({ transportState: "closed" });
733
+ const send = sendOpenclawClawlingText({
734
+ client,
735
+ account: baseAccount(),
736
+ to: { chatId: "chat-1", chatType: "direct" },
737
+ text: "hello",
738
+ });
739
+
740
+ await Promise.resolve();
741
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
742
+
743
+ client.emit("close", { code: 1000, reason: "client close" });
744
+
745
+ await expect(send).rejects.toThrow(/send cancelled because client close/);
746
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
747
+
748
+ client.setTransportState("open");
749
+ flushAlignedOutboundQueue(client);
750
+ expect(client.sent).toHaveLength(0);
751
+ });
752
+
753
+ it("cancels a queued ackable send on disconnected state without close event", async () => {
754
+ const client = mockClient({ transportState: "closed" });
755
+ const send = sendOpenclawClawlingText({
756
+ client,
757
+ account: baseAccount(),
758
+ to: { chatId: "chat-1", chatType: "direct" },
759
+ text: "hello",
760
+ });
761
+
762
+ await Promise.resolve();
763
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
764
+
765
+ client.emit("state", { from: "connected", to: "disconnected" });
766
+
767
+ await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
768
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
769
+
770
+ client.setTransportState("open");
771
+ flushAlignedOutboundQueue(client);
772
+ expect(client.sent).toHaveLength(0);
773
+ });
774
+
775
+ it("rejects a new ackable send when the client is already disconnected", async () => {
776
+ const client = mockClient({ transportState: "closed", state: "disconnected" });
777
+ const send = sendOpenclawClawlingText({
778
+ client,
779
+ account: baseAccount(),
780
+ to: { chatId: "chat-1", chatType: "direct" },
781
+ text: "hello",
782
+ });
783
+
784
+ await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
785
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
786
+ expect(client.sent).toHaveLength(0);
787
+ });
788
+
789
+ it("keeps ackable send pending when a ready-state write fails and resolves after retry", async () => {
790
+ const client = mockClient();
791
+ let attempts = 0;
792
+ client.sendWire = vi.fn((wire: string) => {
793
+ attempts += 1;
794
+ if (attempts === 1) throw new Error("socket closed");
795
+ client.sent.push(wire);
796
+ }) as ClawlingChatClient["sendWire"];
797
+
798
+ const send = sendOpenclawClawlingText({
799
+ client,
800
+ account: baseAccount(),
801
+ to: { chatId: "chat-1", chatType: "direct" },
802
+ text: "hello",
803
+ });
804
+ const observed = send.then(
805
+ (result) => ({ status: "resolved" as const, result }),
806
+ (err: unknown) => ({ status: "rejected" as const, err }),
807
+ );
808
+
809
+ await Promise.resolve();
810
+
811
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
812
+ expect(client.sent).toHaveLength(0);
813
+
814
+ flushAlignedOutboundQueue(client);
815
+ const frame = decodeSent(client)[0]!;
816
+ emitAck(client, frame.trace_id, { message_id: "server-retry", accepted_at: 1234 });
817
+
818
+ await expect(observed).resolves.toMatchObject({
819
+ status: "resolved",
820
+ result: { messageId: "server-retry", acceptedAt: 1234 },
821
+ });
822
+ expect(client.sendWire).toHaveBeenCalledTimes(2);
823
+ });
824
+
825
+ it("requeues a written ackable frame on disconnect before ack", async () => {
826
+ const logs: string[] = [];
827
+ const client = mockClient();
828
+
829
+ const send = sendOpenclawClawlingText({
830
+ client,
831
+ account: baseAccount(),
832
+ to: { chatId: "chat-1", chatType: "direct" },
833
+ text: "hello",
834
+ messageId: "local-msg-1",
835
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
836
+ });
837
+
838
+ await Promise.resolve();
839
+ const firstFrame = decodeSent(client)[0]!;
840
+ expect(firstFrame.event).toBe("message.send");
841
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
842
+
843
+ client.setTransportState("closed");
844
+ client.emit("close", { code: 1006, reason: "network lost" });
845
+
846
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
847
+
848
+ client.setTransportState("open");
849
+ flushAlignedOutboundQueue(client);
850
+
851
+ const frames = decodeSent(client);
852
+ expect(frames).toHaveLength(2);
853
+ expect(frames[1]).toMatchObject({
854
+ event: firstFrame.event,
855
+ trace_id: firstFrame.trace_id,
856
+ chat_id: firstFrame.chat_id,
857
+ payload: firstFrame.payload,
858
+ });
859
+
860
+ emitAck(client, firstFrame.trace_id, { message_id: "local-msg-1", accepted_at: 2222 });
861
+
862
+ await expect(send).resolves.toMatchObject({
863
+ messageId: "local-msg-1",
864
+ acceptedAt: 2222,
865
+ });
866
+ expect(logs.some((line) => line.includes("event=send_queued") && line.includes(`trace_id=${firstFrame.trace_id}`))).toBe(true);
867
+ });
247
868
  });