@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21

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 (99) hide show
  1. package/README.md +39 -17
  2. package/dist/index.js +3 -1
  3. package/dist/src/api-client.js +71 -12
  4. package/dist/src/api-types.test-d.js +10 -0
  5. package/dist/src/channel.js +5 -5
  6. package/dist/src/channel.setup.js +4 -17
  7. package/dist/src/clawchat-memory.js +290 -0
  8. package/dist/src/clawchat-metadata.js +235 -0
  9. package/dist/src/client.js +31 -93
  10. package/dist/src/commands.js +3 -3
  11. package/dist/src/config.js +58 -3
  12. package/dist/src/group-message-coalescer.js +107 -0
  13. package/dist/src/inbound.js +24 -28
  14. package/dist/src/login.runtime.js +82 -19
  15. package/dist/src/media-runtime.js +2 -3
  16. package/dist/src/message-mapper.js +1 -1
  17. package/dist/src/mock-transport.js +31 -0
  18. package/dist/src/outbound.js +281 -56
  19. package/dist/src/plugin-prompts.js +76 -0
  20. package/dist/src/profile-prompt.js +150 -0
  21. package/dist/src/profile-sync.js +169 -0
  22. package/dist/src/prompt-injection.js +25 -0
  23. package/dist/src/protocol-types.js +63 -0
  24. package/dist/src/protocol-types.typecheck.js +1 -0
  25. package/dist/src/protocol.js +2 -2
  26. package/dist/src/reply-dispatcher.js +143 -40
  27. package/dist/src/runtime.js +813 -109
  28. package/dist/src/storage.js +636 -0
  29. package/dist/src/tools-schema.js +70 -10
  30. package/dist/src/tools.js +600 -112
  31. package/dist/src/ws-alignment.js +8 -0
  32. package/dist/src/ws-client.js +588 -0
  33. package/index.ts +6 -1
  34. package/openclaw.plugin.json +44 -4
  35. package/package.json +4 -3
  36. package/prompts/platform.md +7 -0
  37. package/skills/clawchat/SKILL.md +90 -0
  38. package/src/api-client.test.ts +360 -15
  39. package/src/api-client.ts +127 -25
  40. package/src/api-types.test-d.ts +12 -0
  41. package/src/api-types.ts +71 -4
  42. package/src/buffered-stream.test.ts +1 -1
  43. package/src/buffered-stream.ts +1 -1
  44. package/src/channel.outbound.test.ts +270 -60
  45. package/src/channel.setup.ts +9 -18
  46. package/src/channel.test.ts +33 -25
  47. package/src/channel.ts +5 -7
  48. package/src/clawchat-memory.test.ts +372 -0
  49. package/src/clawchat-memory.ts +363 -0
  50. package/src/clawchat-metadata.test.ts +350 -0
  51. package/src/clawchat-metadata.ts +352 -0
  52. package/src/client.test.ts +57 -48
  53. package/src/client.ts +37 -129
  54. package/src/commands.test.ts +2 -2
  55. package/src/commands.ts +3 -3
  56. package/src/config.test.ts +169 -4
  57. package/src/config.ts +86 -6
  58. package/src/group-message-coalescer.test.ts +223 -0
  59. package/src/group-message-coalescer.ts +154 -0
  60. package/src/inbound.test.ts +106 -19
  61. package/src/inbound.ts +31 -35
  62. package/src/login.runtime.test.ts +294 -11
  63. package/src/login.runtime.ts +90 -21
  64. package/src/manifest.test.ts +86 -14
  65. package/src/media-runtime.test.ts +31 -2
  66. package/src/media-runtime.ts +7 -10
  67. package/src/message-mapper.test.ts +2 -2
  68. package/src/message-mapper.ts +2 -2
  69. package/src/mock-transport.test.ts +35 -0
  70. package/src/mock-transport.ts +38 -0
  71. package/src/outbound.test.ts +811 -95
  72. package/src/outbound.ts +332 -65
  73. package/src/plugin-entry.test.ts +3 -1
  74. package/src/plugin-prompts.test.ts +78 -0
  75. package/src/plugin-prompts.ts +92 -0
  76. package/src/profile-prompt.test.ts +435 -0
  77. package/src/profile-prompt.ts +208 -0
  78. package/src/profile-sync.test.ts +611 -0
  79. package/src/profile-sync.ts +268 -0
  80. package/src/prompt-injection.test.ts +39 -0
  81. package/src/prompt-injection.ts +45 -0
  82. package/src/protocol-types.test.ts +69 -0
  83. package/src/protocol-types.ts +296 -0
  84. package/src/protocol-types.typecheck.ts +89 -0
  85. package/src/protocol.ts +2 -2
  86. package/src/reply-dispatcher.test.ts +720 -135
  87. package/src/reply-dispatcher.ts +174 -42
  88. package/src/runtime.test.ts +3884 -337
  89. package/src/runtime.ts +956 -128
  90. package/src/storage.test.ts +692 -0
  91. package/src/storage.ts +989 -0
  92. package/src/streaming.test.ts +1 -1
  93. package/src/streaming.ts +1 -1
  94. package/src/tools-schema.ts +115 -13
  95. package/src/tools.test.ts +501 -10
  96. package/src/tools.ts +739 -133
  97. package/src/ws-alignment.ts +9 -0
  98. package/src/ws-client.test.ts +1218 -0
  99. package/src/ws-client.ts +662 -0
@@ -1,10 +1,13 @@
1
- import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
2
1
  import { EventEmitter } from "node:events";
3
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
4
3
  import { describe, expect, it, vi } from "vitest";
5
4
  import type { ResolvedOpenclawClawlingAccount } from "./config.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";
6
8
  import {
7
9
  flushAlignedOutboundQueue,
10
+ getAlignedOutboundQueueSize,
8
11
  sendOpenclawClawlingMedia,
9
12
  sendOpenclawClawlingText,
10
13
  } from "./outbound.ts";
@@ -21,6 +24,7 @@ function baseAccount(
21
24
  baseUrl: "",
22
25
  token: "tk",
23
26
  userId: "agent-1",
27
+ ownerUserId: "owner-1",
24
28
  replyMode: "static",
25
29
  forwardThinking: true,
26
30
  forwardToolCalls: false,
@@ -38,49 +42,283 @@ function baseAccount(
38
42
  };
39
43
  }
40
44
 
41
- function mockClient() {
42
- return {
43
- sendMessage: vi.fn().mockResolvedValue({
44
- version: "2",
45
- event: "message.ack",
46
- trace_id: "trace-ack",
47
- emitted_at: Date.now(),
48
- payload: { message_id: "server-m1", accepted_at: 1234 },
49
- }),
50
- replyMessage: vi.fn().mockResolvedValue({
51
- version: "2",
52
- event: "message.ack",
53
- trace_id: "trace-ack-r",
54
- emitted_at: Date.now(),
55
- 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);
56
68
  }),
57
69
  typing: vi.fn(),
58
70
  emitRaw: vi.fn(),
59
- } 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;
60
130
  }
61
131
 
62
132
  describe("openclaw-clawchat outbound", () => {
63
- it("routes to sendMessage when no replyCtx", async () => {
133
+ it("sends the legacy silent response marker", async () => {
134
+ const client = mockClient();
135
+ const send = sendOpenclawClawlingText({
136
+ client,
137
+ account: baseAccount(),
138
+ to: { chatId: "group-1", chatType: "group" },
139
+ text: "<clawchat:silent/>",
140
+ });
141
+ const frame = decodeSent(client)[0]!;
142
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
143
+ { kind: "text", text: "<clawchat:silent/>" },
144
+ ]);
145
+ emitAck(client, frame.trace_id, { message_id: "msg-legacy-silent", accepted_at: Date.now() });
146
+ const result = await send;
147
+
148
+ expect(result?.messageId).toBe("msg-legacy-silent");
149
+ });
150
+
151
+ it("suppresses the quoted empty response marker", async () => {
64
152
  const client = mockClient();
65
153
  const result = await sendOpenclawClawlingText({
154
+ client,
155
+ account: baseAccount(),
156
+ to: { chatId: "group-1", chatType: "group" },
157
+ text: '""',
158
+ });
159
+
160
+ expect(result).toBeNull();
161
+ expect(decodeSent(client)).toEqual([]);
162
+ });
163
+
164
+ it("sends text wrapped in Chinese full-width parentheses", async () => {
165
+ const client = mockClient();
166
+ const send = sendOpenclawClawlingText({
167
+ client,
168
+ account: baseAccount(),
169
+ to: { chatId: "group-1", chatType: "group" },
170
+ text: "(我保持沉默)",
171
+ });
172
+ const frame = decodeSent(client)[0]!;
173
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
174
+ { kind: "text", text: "(我保持沉默)" },
175
+ ]);
176
+ emitAck(client, frame.trace_id, { message_id: "msg-chinese-parentheses", accepted_at: Date.now() });
177
+ const result = await send;
178
+
179
+ expect(result?.messageId).toBe("msg-chinese-parentheses");
180
+ });
181
+
182
+ it("sends text wrapped in ASCII parentheses", async () => {
183
+ const client = mockClient();
184
+ const send = sendOpenclawClawlingText({
185
+ client,
186
+ account: baseAccount(),
187
+ to: { chatId: "group-1", chatType: "group" },
188
+ text: "(staying silent)",
189
+ });
190
+ const frame = decodeSent(client)[0]!;
191
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
192
+ { kind: "text", text: "(staying silent)" },
193
+ ]);
194
+ emitAck(client, frame.trace_id, { message_id: "msg-ascii-parentheses", accepted_at: Date.now() });
195
+ const result = await send;
196
+
197
+ expect(result?.messageId).toBe("msg-ascii-parentheses");
198
+ });
199
+
200
+ it("sends nonempty quoted text", async () => {
201
+ const client = mockClient();
202
+ const send = sendOpenclawClawlingText({
203
+ client,
204
+ account: baseAccount(),
205
+ to: { chatId: "group-1", chatType: "group" },
206
+ text: '"hello"',
207
+ });
208
+ const frame = decodeSent(client)[0]!;
209
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
210
+ { kind: "text", text: '"hello"' },
211
+ ]);
212
+ emitAck(client, frame.trace_id, { message_id: "msg-quoted-text", accepted_at: Date.now() });
213
+ const result = await send;
214
+
215
+ expect(result?.messageId).toBe("msg-quoted-text");
216
+ });
217
+
218
+ it("does not suppress the silent response marker when media is present", async () => {
219
+ const client = mockClient();
220
+ const send = sendOpenclawClawlingText({
221
+ client,
222
+ account: baseAccount(),
223
+ to: { chatId: "group-1", chatType: "group" },
224
+ text: "<clawchat:silent/>",
225
+ mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
226
+ });
227
+ const frame = decodeSent(client)[0]!;
228
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
229
+ { kind: "text", text: "<clawchat:silent/>" },
230
+ { kind: "image", url: "https://cdn/x.png" },
231
+ ]);
232
+ emitAck(client, frame.trace_id, { message_id: "msg-with-media", accepted_at: Date.now() });
233
+ const result = await send;
234
+
235
+ expect(result?.messageId).toBe("msg-with-media");
236
+ });
237
+
238
+ it("does not suppress text wrapped in Chinese full-width parentheses when media is present", async () => {
239
+ const client = mockClient();
240
+ const send = sendOpenclawClawlingText({
241
+ client,
242
+ account: baseAccount(),
243
+ to: { chatId: "group-1", chatType: "group" },
244
+ text: "(我保持沉默)",
245
+ mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
246
+ });
247
+ const frame = decodeSent(client)[0]!;
248
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
249
+ { kind: "text", text: "(我保持沉默)" },
250
+ { kind: "image", url: "https://cdn/x.png" },
251
+ ]);
252
+ emitAck(client, frame.trace_id, { message_id: "msg-with-chinese-marker-media", accepted_at: Date.now() });
253
+ const result = await send;
254
+
255
+ expect(result?.messageId).toBe("msg-with-chinese-marker-media");
256
+ });
257
+
258
+ it("does not suppress text wrapped in ASCII parentheses when media is present", async () => {
259
+ const client = mockClient();
260
+ const send = sendOpenclawClawlingText({
261
+ client,
262
+ account: baseAccount(),
263
+ to: { chatId: "group-1", chatType: "group" },
264
+ text: "(staying silent)",
265
+ mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
266
+ });
267
+ const frame = decodeSent(client)[0]!;
268
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
269
+ { kind: "text", text: "(staying silent)" },
270
+ { kind: "image", url: "https://cdn/x.png" },
271
+ ]);
272
+ emitAck(client, frame.trace_id, { message_id: "msg-with-ascii-marker-media", accepted_at: Date.now() });
273
+ const result = await send;
274
+
275
+ expect(result?.messageId).toBe("msg-with-ascii-marker-media");
276
+ });
277
+
278
+ it("sends text that only contains the silent response marker", async () => {
279
+ const client = mockClient();
280
+ const send = sendOpenclawClawlingText({
281
+ client,
282
+ account: baseAccount(),
283
+ to: { chatId: "group-1", chatType: "group" },
284
+ text: "note: <clawchat:silent/>",
285
+ });
286
+ const frame = decodeSent(client)[0]!;
287
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
288
+ { kind: "text", text: "note: <clawchat:silent/>" },
289
+ ]);
290
+ emitAck(client, frame.trace_id, { message_id: "msg-normal-text", accepted_at: Date.now() });
291
+ const result = await send;
292
+
293
+ expect(result?.messageId).toBe("msg-normal-text");
294
+ });
295
+
296
+ it("sends message.send when no replyCtx", async () => {
297
+ const client = mockClient();
298
+ const send = sendOpenclawClawlingText({
66
299
  client,
67
300
  account: baseAccount(),
68
301
  to: { chatId: "user-1", chatType: "direct" },
69
302
  text: "hello",
70
303
  });
71
- expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
304
+ const frame = decodeSent(client)[0]!;
305
+ expect(frame).toMatchObject({
306
+ event: "message.send",
72
307
  chat_id: "user-1",
73
- body: { fragments: [{ kind: "text", text: "hello" }] },
308
+ payload: {
309
+ message: { body: { fragments: [{ kind: "text", text: "hello" }] } },
310
+ },
74
311
  });
75
- expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
76
- expect(client.replyMessage).not.toHaveBeenCalled();
312
+ expect(frame).not.toHaveProperty("chat_type");
313
+ emitAck(client, frame.trace_id);
314
+ const result = await send;
77
315
  expect(result?.messageId).toBe("server-m1");
78
316
  expect(result?.acceptedAt).toBe(1234);
79
317
  });
80
318
 
81
- it("routes to replyMessage when replyCtx is provided", async () => {
319
+ it("sends message.reply when replyCtx is provided", async () => {
82
320
  const client = mockClient();
83
- await sendOpenclawClawlingText({
321
+ const send = sendOpenclawClawlingText({
84
322
  client,
85
323
  account: baseAccount(),
86
324
  to: { chatId: "chat-1", chatType: "direct" },
@@ -93,18 +331,346 @@ describe("openclaw-clawchat outbound", () => {
93
331
  replyPreviewText: "original",
94
332
  },
95
333
  });
96
- expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
334
+ const frame = decodeSent(client)[0]!;
335
+ expect(frame).toMatchObject({
336
+ event: "message.reply",
97
337
  chat_id: "chat-1",
98
- replyTo: {
99
- msgId: "m-orig",
100
- senderId: "user-2",
101
- nickName: "Sender",
102
- fragments: [{ kind: "text", text: "original" }],
338
+ payload: {
339
+ message: {
340
+ body: { fragments: [{ kind: "text", text: "reply" }] },
341
+ context: {
342
+ reply: {
343
+ reply_to_msg_id: "m-orig",
344
+ reply_preview: {
345
+ id: "user-2",
346
+ nick_name: "Sender",
347
+ fragments: [{ kind: "text", text: "original" }],
348
+ },
349
+ },
350
+ },
351
+ },
103
352
  },
104
- body: { fragments: [{ kind: "text", text: "reply" }] },
105
353
  });
106
- expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
107
- expect(client.sendMessage).not.toHaveBeenCalled();
354
+ expect(frame).not.toHaveProperty("chat_type");
355
+ emitAck(client, frame.trace_id, { message_id: "server-r1", accepted_at: 5678 });
356
+ await expect(send).resolves.toMatchObject({ messageId: "server-r1", acceptedAt: 5678 });
357
+ });
358
+
359
+ it("sends static outbound with caller supplied payload message_id", async () => {
360
+ const client = mockClient();
361
+ const send = sendOpenclawClawlingText({
362
+ client,
363
+ account: baseAccount(),
364
+ to: { chatId: "chat-1", chatType: "direct" },
365
+ text: "hello",
366
+ messageId: "local-msg-1",
367
+ });
368
+ const frame = decodeSent(client)[0]!;
369
+ expect((frame.payload as { message_id?: string }).message_id).toBe("local-msg-1");
370
+ emitAck(client, frame.trace_id, { message_id: "local-msg-1", accepted_at: 1234 });
371
+ await expect(send).resolves.toMatchObject({ messageId: "local-msg-1" });
372
+ });
373
+
374
+ it("rejects static outbound when ack message_id does not match", async () => {
375
+ const client = mockClient();
376
+ const send = sendOpenclawClawlingText({
377
+ client,
378
+ account: baseAccount(),
379
+ to: { chatId: "chat-1", chatType: "direct" },
380
+ text: "hello",
381
+ messageId: "local-msg-1",
382
+ });
383
+ const frame = decodeSent(client)[0]!;
384
+ emitAck(client, frame.trace_id, { message_id: "other-msg", accepted_at: 1234 });
385
+ await expect(send).rejects.toThrow("ack message_id mismatch");
386
+ });
387
+
388
+ it("rejects aligned outbound sends from matching message.error", async () => {
389
+ vi.useFakeTimers();
390
+ try {
391
+ const client = mockClient();
392
+ const send = sendOpenclawClawlingText({
393
+ client,
394
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
395
+ to: { chatId: "missing-chat", chatType: "direct" },
396
+ text: "hello",
397
+ });
398
+ const frame = decodeSent(client)[0]!;
399
+
400
+ emitMessageError(client, frame.trace_id);
401
+
402
+ await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
403
+ await vi.advanceTimersByTimeAsync(1000);
404
+ } finally {
405
+ vi.useRealTimers();
406
+ }
407
+ });
408
+
409
+ it("ignores unmatched message.error without rejecting unrelated aligned sends", async () => {
410
+ vi.useFakeTimers();
411
+ try {
412
+ const client = mockClient();
413
+ const logs: string[] = [];
414
+ const send = sendOpenclawClawlingText({
415
+ client,
416
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
417
+ to: { chatId: "chat-1", chatType: "direct" },
418
+ text: "hello",
419
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
420
+ });
421
+ const frame = decodeSent(client)[0]!;
422
+
423
+ emitMessageError(client, "trace-other");
424
+
425
+ expect(logs).toContain(
426
+ "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",
427
+ );
428
+
429
+ await vi.advanceTimersByTimeAsync(999);
430
+ const pendingOutcome = await Promise.race([
431
+ send.then(() => "resolved", (err) => err),
432
+ Promise.resolve("pending"),
433
+ ]);
434
+ expect(pendingOutcome).toBe("pending");
435
+
436
+ emitAck(client, frame.trace_id, { message_id: "server-m1", accepted_at: 1234 });
437
+ await expect(send).resolves.toMatchObject({ messageId: "server-m1", acceptedAt: 1234 });
438
+ } finally {
439
+ vi.useRealTimers();
440
+ }
441
+ });
442
+
443
+ it("rejects only the matching aligned send without per-send unmatched logs", async () => {
444
+ const client = mockClient();
445
+ const logs: string[] = [];
446
+ const log = { info: (msg: string) => logs.push(msg), error: (msg: string) => logs.push(msg) };
447
+ const account = baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } });
448
+
449
+ const first = sendOpenclawClawlingText({
450
+ client,
451
+ account,
452
+ to: { chatId: "missing-chat", chatType: "direct" },
453
+ text: "first",
454
+ log,
455
+ });
456
+ const second = sendOpenclawClawlingText({
457
+ client,
458
+ account,
459
+ to: { chatId: "chat-2", chatType: "direct" },
460
+ text: "second",
461
+ log,
462
+ });
463
+ const [firstFrame, secondFrame] = decodeSent(client);
464
+
465
+ emitMessageError(client, firstFrame!.trace_id);
466
+
467
+ await expect(first).rejects.toThrow(/chat_not_found.*chat not resolvable/);
468
+ expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
469
+
470
+ emitAck(client, secondFrame!.trace_id, { message_id: "server-second", accepted_at: 1234 });
471
+ await expect(second).resolves.toMatchObject({ messageId: "server-second" });
472
+ });
473
+
474
+ it("does not let core websocket warn after aligned outbound handles message.error", async () => {
475
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
476
+ try {
477
+ const transport = new MockTransport();
478
+ const client = createClawChatClient({
479
+ url: "ws://test",
480
+ token: "token-1",
481
+ deviceId: "agent-1",
482
+ transport,
483
+ traceIdFactory: vi.fn()
484
+ .mockReturnValueOnce("trace-connect")
485
+ .mockReturnValueOnce("trace-aligned"),
486
+ ack: { timeout: 1000, autoResendOnTimeout: false },
487
+ heartbeat: { enabled: false },
488
+ });
489
+ await connectReady(transport, client);
490
+
491
+ const send = sendOpenclawClawlingText({
492
+ client,
493
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
494
+ to: { chatId: "missing-chat", chatType: "direct" },
495
+ text: "hello",
496
+ });
497
+ const frame = decodeTransportSent(transport).find((env) => env.event === "message.send")!;
498
+ transport.emitInbound(JSON.stringify({
499
+ version: "2",
500
+ event: "message.error",
501
+ trace_id: frame.trace_id,
502
+ emitted_at: Date.now(),
503
+ chat_id: "missing-chat",
504
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
505
+ }));
506
+
507
+ await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
508
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
509
+ } finally {
510
+ warn.mockRestore();
511
+ }
512
+ });
513
+
514
+ it("logs truly unmatched message.error once when aligned tracker is installed", async () => {
515
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
516
+ try {
517
+ const transport = new MockTransport();
518
+ const client = createClawChatClient({
519
+ url: "ws://test",
520
+ token: "token-1",
521
+ deviceId: "agent-1",
522
+ transport,
523
+ traceIdFactory: vi.fn()
524
+ .mockReturnValueOnce("trace-connect")
525
+ .mockReturnValueOnce("trace-aligned"),
526
+ ack: { timeout: 1000, autoResendOnTimeout: false },
527
+ heartbeat: { enabled: false },
528
+ });
529
+ await connectReady(transport, client);
530
+ const logs: string[] = [];
531
+
532
+ const aligned = sendOpenclawClawlingText({
533
+ client,
534
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
535
+ to: { chatId: "chat-aligned", chatType: "direct" },
536
+ text: "aligned",
537
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
538
+ });
539
+
540
+ transport.emitInbound(JSON.stringify({
541
+ version: "2",
542
+ event: "message.error",
543
+ trace_id: "trace-unmatched",
544
+ emitted_at: Date.now(),
545
+ chat_id: "missing-chat",
546
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
547
+ }));
548
+
549
+ expect(logs.filter((line) => line.includes("event=ack_unmatched"))).toEqual([
550
+ "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",
551
+ ]);
552
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
553
+
554
+ transport.emitInbound(JSON.stringify({
555
+ version: "2",
556
+ event: "message.ack",
557
+ trace_id: "trace-aligned",
558
+ emitted_at: Date.now(),
559
+ chat_id: "chat-aligned",
560
+ payload: { message_id: "server-aligned", accepted_at: 1234 },
561
+ }));
562
+ await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
563
+ } finally {
564
+ warn.mockRestore();
565
+ }
566
+ });
567
+
568
+ it("does not swallow truly unmatched message.error when tracker was installed without log sink", async () => {
569
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
570
+ try {
571
+ const transport = new MockTransport();
572
+ const client = createClawChatClient({
573
+ url: "ws://test",
574
+ token: "token-1",
575
+ deviceId: "agent-1",
576
+ transport,
577
+ traceIdFactory: vi.fn()
578
+ .mockReturnValueOnce("trace-connect")
579
+ .mockReturnValueOnce("trace-aligned"),
580
+ ack: { timeout: 1000, autoResendOnTimeout: false },
581
+ heartbeat: { enabled: false },
582
+ });
583
+ await connectReady(transport, client);
584
+
585
+ const aligned = sendOpenclawClawlingText({
586
+ client,
587
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
588
+ to: { chatId: "chat-aligned", chatType: "direct" },
589
+ text: "aligned",
590
+ });
591
+
592
+ transport.emitInbound(JSON.stringify({
593
+ version: "2",
594
+ event: "message.error",
595
+ trace_id: "trace-unmatched",
596
+ emitted_at: Date.now(),
597
+ chat_id: "missing-chat",
598
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
599
+ }));
600
+
601
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
602
+
603
+ transport.emitInbound(JSON.stringify({
604
+ version: "2",
605
+ event: "message.ack",
606
+ trace_id: "trace-aligned",
607
+ emitted_at: Date.now(),
608
+ chat_id: "chat-aligned",
609
+ payload: { message_id: "server-aligned", accepted_at: 1234 },
610
+ }));
611
+ await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
612
+ } finally {
613
+ warn.mockRestore();
614
+ }
615
+ });
616
+
617
+ it("does not log aligned unmatched when message.error matches a core pending send", async () => {
618
+ const transport = new MockTransport();
619
+ const client = createClawChatClient({
620
+ url: "ws://test",
621
+ token: "token-1",
622
+ deviceId: "agent-1",
623
+ transport,
624
+ traceIdFactory: vi.fn()
625
+ .mockReturnValueOnce("trace-connect")
626
+ .mockReturnValueOnce("trace-aligned")
627
+ .mockReturnValueOnce("trace-core"),
628
+ ack: { timeout: 1000, autoResendOnTimeout: false },
629
+ heartbeat: { enabled: false },
630
+ });
631
+ await connectReady(transport, client);
632
+ const logs: string[] = [];
633
+
634
+ const aligned = sendOpenclawClawlingText({
635
+ client,
636
+ account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
637
+ to: { chatId: "chat-aligned", chatType: "direct" },
638
+ text: "aligned",
639
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
640
+ });
641
+ const core = client.sendAckableEnvelope({
642
+ eventName: "message.send",
643
+ chatId: "missing-chat",
644
+ payload: {
645
+ message_mode: "normal",
646
+ message: {
647
+ body: { fragments: [{ kind: "text", text: "core" }] },
648
+ context: { mentions: [], reply: null },
649
+ },
650
+ },
651
+ });
652
+
653
+ transport.emitInbound(JSON.stringify({
654
+ version: "2",
655
+ event: "message.error",
656
+ trace_id: "trace-core",
657
+ emitted_at: Date.now(),
658
+ chat_id: "missing-chat",
659
+ payload: { code: "chat_not_found", message: "chat not resolvable" },
660
+ }));
661
+
662
+ await expect(core).rejects.toThrow(/chat_not_found.*chat not resolvable/);
663
+ expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
664
+
665
+ transport.emitInbound(JSON.stringify({
666
+ version: "2",
667
+ event: "message.ack",
668
+ trace_id: "trace-aligned",
669
+ emitted_at: Date.now(),
670
+ chat_id: "chat-aligned",
671
+ payload: { message_id: "server-aligned", accepted_at: 1234 },
672
+ }));
673
+ await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
108
674
  });
109
675
 
110
676
  it("suppresses send when text is empty after trim", async () => {
@@ -115,60 +681,48 @@ describe("openclaw-clawchat outbound", () => {
115
681
  to: { chatId: "u", chatType: "direct" },
116
682
  text: " ",
117
683
  });
118
- expect(client.sendMessage).not.toHaveBeenCalled();
119
- expect(client.replyMessage).not.toHaveBeenCalled();
684
+ expect(client.sent).toHaveLength(0);
120
685
  expect(result).toBeNull();
121
686
  });
122
687
 
123
- it("propagates SDK errors", async () => {
124
- const client = {
125
- ...mockClient(),
126
- sendMessage: vi.fn().mockRejectedValue(new Error("boom")),
127
- } as unknown as ClawlingChatClient;
128
- await expect(
129
- sendOpenclawClawlingText({
130
- client,
131
- account: baseAccount(),
132
- to: { chatId: "u", chatType: "direct" },
133
- text: "hi",
134
- }),
135
- ).rejects.toThrow("boom");
136
- });
137
-
138
688
  it("appends mediaFragments after text in body.fragments", async () => {
139
689
  const client = mockClient();
140
- await sendOpenclawClawlingText({
690
+ const send = sendOpenclawClawlingText({
141
691
  client,
142
692
  account: baseAccount(),
143
693
  to: { chatId: "user-1", chatType: "direct" },
144
694
  text: "look",
145
695
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
146
696
  });
147
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
148
- expect(callArg.body.fragments).toEqual([
697
+ const frame = decodeSent(client)[0]!;
698
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
149
699
  { kind: "text", text: "look" },
150
700
  { kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
151
701
  ]);
702
+ emitAck(client, frame.trace_id);
703
+ await send;
152
704
  });
153
705
 
154
706
  it("sends media-only message when text empty but mediaFragments present", async () => {
155
707
  const client = mockClient();
156
- const result = await sendOpenclawClawlingText({
708
+ const send = sendOpenclawClawlingText({
157
709
  client,
158
710
  account: baseAccount(),
159
711
  to: { chatId: "user-1", chatType: "direct" },
160
712
  text: "",
161
713
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
162
714
  });
163
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
164
- expect(callArg.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
715
+ const frame = decodeSent(client)[0]!;
716
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
717
+ emitAck(client, frame.trace_id);
718
+ const result = await send;
165
719
  expect(result?.messageId).toBe("server-m1");
166
720
  });
167
721
 
168
722
  it("preserves replyCtx when media fragments are present", async () => {
169
723
  const client = mockClient();
170
724
  const log = { info: vi.fn(), error: vi.fn() };
171
- await sendOpenclawClawlingText({
725
+ const send = sendOpenclawClawlingText({
172
726
  client,
173
727
  account: baseAccount(),
174
728
  to: { chatId: "user-1", chatType: "direct" },
@@ -182,18 +736,24 @@ describe("openclaw-clawchat outbound", () => {
182
736
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
183
737
  log,
184
738
  });
185
- expect(client.replyMessage).toHaveBeenCalled();
186
- expect(client.sendMessage).not.toHaveBeenCalled();
187
- const callArg = (client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
188
- expect(callArg.replyTo).toMatchObject({
189
- msgId: "m-orig",
190
- senderId: "user-2",
191
- nickName: "Sender",
739
+ const frame = decodeSent(client)[0]!;
740
+ const payload = frame.payload as {
741
+ message: {
742
+ body: { fragments: unknown[] };
743
+ context: { reply: { reply_to_msg_id: string; reply_preview: { id: string; nick_name: string } } };
744
+ };
745
+ };
746
+ expect(frame.event).toBe("message.reply");
747
+ expect(payload.message.context.reply).toMatchObject({
748
+ reply_to_msg_id: "m-orig",
749
+ reply_preview: { id: "user-2", nick_name: "Sender" },
192
750
  });
193
- expect(callArg.body.fragments).toEqual([
751
+ expect(payload.message.body.fragments).toEqual([
194
752
  { kind: "text", text: "hi" },
195
753
  { kind: "image", url: "https://cdn/x.png" },
196
754
  ]);
755
+ emitAck(client, frame.trace_id);
756
+ await send;
197
757
  expect(log.info).not.toHaveBeenCalledWith(
198
758
  expect.stringMatching(/replyCtx \+ media: downgraded to sendMessage/),
199
759
  );
@@ -208,41 +768,45 @@ describe("openclaw-clawchat outbound", () => {
208
768
  text: " ",
209
769
  mediaFragments: [],
210
770
  });
211
- expect(client.sendMessage).not.toHaveBeenCalled();
771
+ expect(client.sent).toHaveLength(0);
212
772
  expect(result).toBeNull();
213
773
  });
214
774
 
215
775
  it("sendOpenclawClawlingMedia with image and caption sends both fragments", async () => {
216
776
  const client = mockClient();
217
- const result = await sendOpenclawClawlingMedia({
777
+ const send = sendOpenclawClawlingMedia({
218
778
  client,
219
779
  account: baseAccount(),
220
780
  to: { chatId: "user-1", chatType: "direct" },
221
781
  text: "look at this",
222
782
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
223
783
  });
224
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
225
- expect(callArg.body.fragments).toEqual([
784
+ const frame = decodeSent(client)[0]!;
785
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
226
786
  { kind: "text", text: "look at this" },
227
787
  { kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
228
788
  ]);
789
+ emitAck(client, frame.trace_id);
790
+ const result = await send;
229
791
  expect(result?.messageId).toBe("server-m1");
230
792
  });
231
793
 
232
794
  it("sendOpenclawClawlingMedia with image only (no text) sends just the media fragment", async () => {
233
795
  const client = mockClient();
234
- const result = await sendOpenclawClawlingMedia({
796
+ const send = sendOpenclawClawlingMedia({
235
797
  client,
236
798
  account: baseAccount(),
237
799
  to: { chatId: "user-1", chatType: "direct" },
238
800
  mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
239
801
  });
240
- const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
241
- expect(callArg.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
802
+ const frame = decodeSent(client)[0]!;
803
+ expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
804
+ emitAck(client, frame.trace_id);
805
+ const result = await send;
242
806
  expect(result?.messageId).toBe("server-m1");
243
807
  });
244
808
 
245
- it("sendOpenclawClawlingMedia returns null and does not call SDK when mediaFragments is empty", async () => {
809
+ it("sendOpenclawClawlingMedia returns null and does not send when mediaFragments is empty", async () => {
246
810
  const client = mockClient();
247
811
  const log = { info: vi.fn(), error: vi.fn() };
248
812
  const result = await sendOpenclawClawlingMedia({
@@ -252,8 +816,7 @@ describe("openclaw-clawchat outbound", () => {
252
816
  mediaFragments: [],
253
817
  log,
254
818
  });
255
- expect(client.sendMessage).not.toHaveBeenCalled();
256
- expect(client.replyMessage).not.toHaveBeenCalled();
819
+ expect(client.sent).toHaveLength(0);
257
820
  expect(result).toBeNull();
258
821
  expect(log.info).toHaveBeenCalledWith(
259
822
  expect.stringMatching(/sendMedia called with empty mediaFragments/),
@@ -263,23 +826,7 @@ describe("openclaw-clawchat outbound", () => {
263
826
  it("starts ack timeout only after a queued ackable frame is written", async () => {
264
827
  vi.useFakeTimers();
265
828
  const logs: string[] = [];
266
- const sent: string[] = [];
267
- const transport = {
268
- state: "closed",
269
- send: vi.fn((wire: string) => {
270
- if (transport.state !== "open") throw new Error("socket closed");
271
- sent.push(wire);
272
- }),
273
- };
274
- let trace = 0;
275
- const client = Object.assign(new EventEmitter(), {
276
- opts: {
277
- transport,
278
- traceIdFactory: () => `trace-${++trace}`,
279
- },
280
- sendMessage: vi.fn(),
281
- replyMessage: vi.fn(),
282
- }) as unknown as ClawlingChatClient;
829
+ const client = mockClient({ transportState: "closed" });
283
830
 
284
831
  let rejected: unknown;
285
832
  const promise = sendOpenclawClawlingText({
@@ -296,11 +843,11 @@ describe("openclaw-clawchat outbound", () => {
296
843
  await Promise.resolve();
297
844
  await vi.advanceTimersByTimeAsync(15001);
298
845
  expect(rejected).toBeUndefined();
299
- expect(sent).toHaveLength(0);
846
+ expect(client.sent).toHaveLength(0);
300
847
 
301
- transport.state = "open";
848
+ client.setTransportState("open");
302
849
  flushAlignedOutboundQueue(client);
303
- expect(sent).toHaveLength(1);
850
+ expect(client.sent).toHaveLength(1);
304
851
 
305
852
  await vi.advanceTimersByTimeAsync(14999);
306
853
  expect(rejected).toBeUndefined();
@@ -312,4 +859,173 @@ describe("openclaw-clawchat outbound", () => {
312
859
  );
313
860
  vi.useRealTimers();
314
861
  });
862
+
863
+ it("rejects an ackable send when queue overflow drops it before write", async () => {
864
+ const logs: string[] = [];
865
+ const client = mockClient({ transportState: "closed" });
866
+ const account = baseAccount();
867
+
868
+ const first = sendOpenclawClawlingText({
869
+ client,
870
+ account,
871
+ to: { chatId: "chat-1", chatType: "direct" },
872
+ text: "first",
873
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
874
+ });
875
+
876
+ for (let i = 0; i < 128; i += 1) {
877
+ sendOpenclawClawlingText({
878
+ client,
879
+ account,
880
+ to: { chatId: "chat-1", chatType: "direct" },
881
+ text: `queued-${i}`,
882
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
883
+ }).catch(() => {});
884
+ }
885
+
886
+ await Promise.resolve();
887
+
888
+ expect(getAlignedOutboundQueueSize(client)).toBe(128);
889
+ expect(client.listenerCount("close")).toBe(1);
890
+ await expect(first).rejects.toThrow(/send queue full/);
891
+ expect(logs.some((line) => line.includes("event=send_queue_drop"))).toBe(true);
892
+ });
893
+
894
+ it("cancels a queued ackable send on terminal close without later flushing it", async () => {
895
+ const client = mockClient({ transportState: "closed" });
896
+ const send = sendOpenclawClawlingText({
897
+ client,
898
+ account: baseAccount(),
899
+ to: { chatId: "chat-1", chatType: "direct" },
900
+ text: "hello",
901
+ });
902
+
903
+ await Promise.resolve();
904
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
905
+
906
+ client.emit("close", { code: 1000, reason: "client close" });
907
+
908
+ await expect(send).rejects.toThrow(/send cancelled because client close/);
909
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
910
+
911
+ client.setTransportState("open");
912
+ flushAlignedOutboundQueue(client);
913
+ expect(client.sent).toHaveLength(0);
914
+ });
915
+
916
+ it("cancels a queued ackable send on disconnected state without close event", async () => {
917
+ const client = mockClient({ transportState: "closed" });
918
+ const send = sendOpenclawClawlingText({
919
+ client,
920
+ account: baseAccount(),
921
+ to: { chatId: "chat-1", chatType: "direct" },
922
+ text: "hello",
923
+ });
924
+
925
+ await Promise.resolve();
926
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
927
+
928
+ client.emit("state", { from: "connected", to: "disconnected" });
929
+
930
+ await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
931
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
932
+
933
+ client.setTransportState("open");
934
+ flushAlignedOutboundQueue(client);
935
+ expect(client.sent).toHaveLength(0);
936
+ });
937
+
938
+ it("rejects a new ackable send when the client is already disconnected", async () => {
939
+ const client = mockClient({ transportState: "closed", state: "disconnected" });
940
+ const send = sendOpenclawClawlingText({
941
+ client,
942
+ account: baseAccount(),
943
+ to: { chatId: "chat-1", chatType: "direct" },
944
+ text: "hello",
945
+ });
946
+
947
+ await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
948
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
949
+ expect(client.sent).toHaveLength(0);
950
+ });
951
+
952
+ it("keeps ackable send pending when a ready-state write fails and resolves after retry", async () => {
953
+ const client = mockClient();
954
+ let attempts = 0;
955
+ client.sendWire = vi.fn((wire: string) => {
956
+ attempts += 1;
957
+ if (attempts === 1) throw new Error("socket closed");
958
+ client.sent.push(wire);
959
+ }) as ClawlingChatClient["sendWire"];
960
+
961
+ const send = sendOpenclawClawlingText({
962
+ client,
963
+ account: baseAccount(),
964
+ to: { chatId: "chat-1", chatType: "direct" },
965
+ text: "hello",
966
+ });
967
+ const observed = send.then(
968
+ (result) => ({ status: "resolved" as const, result }),
969
+ (err: unknown) => ({ status: "rejected" as const, err }),
970
+ );
971
+
972
+ await Promise.resolve();
973
+
974
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
975
+ expect(client.sent).toHaveLength(0);
976
+
977
+ flushAlignedOutboundQueue(client);
978
+ const frame = decodeSent(client)[0]!;
979
+ emitAck(client, frame.trace_id, { message_id: "server-retry", accepted_at: 1234 });
980
+
981
+ await expect(observed).resolves.toMatchObject({
982
+ status: "resolved",
983
+ result: { messageId: "server-retry", acceptedAt: 1234 },
984
+ });
985
+ expect(client.sendWire).toHaveBeenCalledTimes(2);
986
+ });
987
+
988
+ it("requeues a written ackable frame on disconnect before ack", async () => {
989
+ const logs: string[] = [];
990
+ const client = mockClient();
991
+
992
+ const send = sendOpenclawClawlingText({
993
+ client,
994
+ account: baseAccount(),
995
+ to: { chatId: "chat-1", chatType: "direct" },
996
+ text: "hello",
997
+ messageId: "local-msg-1",
998
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
999
+ });
1000
+
1001
+ await Promise.resolve();
1002
+ const firstFrame = decodeSent(client)[0]!;
1003
+ expect(firstFrame.event).toBe("message.send");
1004
+ expect(getAlignedOutboundQueueSize(client)).toBe(0);
1005
+
1006
+ client.setTransportState("closed");
1007
+ client.emit("close", { code: 1006, reason: "network lost" });
1008
+
1009
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
1010
+
1011
+ client.setTransportState("open");
1012
+ flushAlignedOutboundQueue(client);
1013
+
1014
+ const frames = decodeSent(client);
1015
+ expect(frames).toHaveLength(2);
1016
+ expect(frames[1]).toMatchObject({
1017
+ event: firstFrame.event,
1018
+ trace_id: firstFrame.trace_id,
1019
+ chat_id: firstFrame.chat_id,
1020
+ payload: firstFrame.payload,
1021
+ });
1022
+
1023
+ emitAck(client, firstFrame.trace_id, { message_id: "local-msg-1", accepted_at: 2222 });
1024
+
1025
+ await expect(send).resolves.toMatchObject({
1026
+ messageId: "local-msg-1",
1027
+ acceptedAt: 2222,
1028
+ });
1029
+ expect(logs.some((line) => line.includes("event=send_queued") && line.includes(`trace_id=${firstFrame.trace_id}`))).toBe(true);
1030
+ });
315
1031
  });