@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,9 +1,617 @@
1
- import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
1
+ import type { ClawlingChatClient } from "./ws-client.ts";
2
+ import { EventEmitter } from "node:events";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
5
+ import {
6
+ flushAlignedOutboundQueue,
7
+ getAlignedOutboundQueueSize,
8
+ } from "./outbound.ts";
9
+
10
+ type SentFrame = {
11
+ event: string;
12
+ payload: Record<string, unknown>;
13
+ chat_id?: string;
14
+ trace_id?: string;
15
+ };
16
+
17
+ type TestClient = ClawlingChatClient & {
18
+ sent: SentFrame[];
19
+ typing: ReturnType<typeof vi.fn>;
20
+ };
21
+
22
+ function mockClient(
23
+ sent: SentFrame[] = [],
24
+ options: { transportState?: "open" | "closed"; state?: string; autoAck?: boolean } = {},
25
+ ): TestClient & { setTransportState: (state: "open" | "closed") => void; setState: (state: string) => void } {
26
+ let trace = 0;
27
+ let transportState = options.transportState ?? "open";
28
+ let clientState = options.state ?? "connected";
29
+ const autoAck = options.autoAck ?? true;
30
+ const client = Object.assign(new EventEmitter(), {
31
+ sent,
32
+ nextTraceId: vi.fn(() => `trace-${++trace}`),
33
+ sendWire: vi.fn((wire: string) => {
34
+ const env = JSON.parse(wire) as SentFrame & { trace_id?: string };
35
+ sent.push({ event: env.event, payload: env.payload, chat_id: env.chat_id, trace_id: env.trace_id });
36
+ if (autoAck && (env.event === "message.send" || env.event === "message.reply")) {
37
+ const payload = env.payload as { message_id?: string };
38
+ queueMicrotask(() => {
39
+ client.emit("raw", {
40
+ version: "2",
41
+ event: "message.ack",
42
+ trace_id: env.trace_id,
43
+ emitted_at: Date.now(),
44
+ payload: { message_id: payload.message_id ?? "server-m1", accepted_at: 1234 },
45
+ });
46
+ });
47
+ }
48
+ }),
49
+ emitRaw: vi.fn((event: string, payload: Record<string, unknown>, routing?: { chat_id?: string }) => {
50
+ sent.push({ event, payload, chat_id: routing?.chat_id });
51
+ }),
52
+ sendRawEnvelope: vi.fn((env: { event: string; payload: Record<string, unknown>; chat_id?: string; trace_id?: string }) => {
53
+ sent.push({ event: env.event, payload: env.payload, chat_id: env.chat_id, trace_id: env.trace_id });
54
+ }),
55
+ typing: vi.fn(),
56
+ setTransportState: (state: "open" | "closed") => {
57
+ transportState = state;
58
+ },
59
+ setState: (state: string) => {
60
+ clientState = state;
61
+ },
62
+ });
63
+ Object.defineProperty(client, "state", { get: () => clientState });
64
+ Object.defineProperty(client, "transportState", { get: () => transportState });
65
+ return client as unknown as TestClient & {
66
+ setTransportState: (state: "open" | "closed") => void;
67
+ setState: (state: string) => void;
68
+ };
69
+ }
70
+
71
+ function runtimeWithHooks(setHooks: (hooks: Record<string, unknown>) => void) {
72
+ return {
73
+ channel: {
74
+ reply: {
75
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
76
+ createReplyDispatcherWithTyping: vi.fn((options) => {
77
+ setHooks(options as Record<string, unknown>);
78
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
79
+ }),
80
+ },
81
+ },
82
+ } as never;
83
+ }
84
+
85
+ function replyAccount(overrides: Record<string, unknown> = {}) {
86
+ return {
87
+ accountId: "default",
88
+ userId: "agent-1",
89
+ replyMode: "static",
90
+ forwardThinking: true,
91
+ forwardToolCalls: false,
92
+ richInteractions: false,
93
+ stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
94
+ ack: { timeout: 10000, autoResendOnTimeout: false },
95
+ ...overrides,
96
+ } as never;
97
+ }
98
+
99
+ type TestStore = {
100
+ claimMessageOnce?: (input: unknown) => true | false | null;
101
+ updateMessageByIdentity?: (input: unknown) => void;
102
+ insertMessage?: (input: unknown) => unknown;
103
+ };
104
+
105
+ async function runStaticReplyWithStore(store: TestStore) {
106
+ let hooks: Record<string, unknown> = {};
107
+ const sent: SentFrame[] = [];
108
+ const client = mockClient(sent);
109
+
110
+ createOpenclawClawlingReplyDispatcher({
111
+ cfg: {} as never,
112
+ runtime: runtimeWithHooks((next) => {
113
+ hooks = next;
114
+ }),
115
+ account: replyAccount(),
116
+ client,
117
+ target: { chatId: "chat-1", chatType: "direct" },
118
+ store: store as never,
119
+ log: { info: vi.fn(), error: vi.fn() },
120
+ });
121
+
122
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
123
+ { text: "static reply" },
124
+ { kind: "final" },
125
+ );
126
+
127
+ return {
128
+ queuedFinal: sent.some((entry) => entry.event === "message.send" || entry.event === "message.reply"),
129
+ sent,
130
+ };
131
+ }
132
+
133
+ async function runStreamingReplyWithStore(store: TestStore, chunks: string[]) {
134
+ let hooks: Record<string, unknown> = {};
135
+ const sent: SentFrame[] = [];
136
+ const client = mockClient(sent);
137
+ const created = createOpenclawClawlingReplyDispatcher({
138
+ cfg: {} as never,
139
+ runtime: runtimeWithHooks((next) => {
140
+ hooks = next;
141
+ }),
142
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
143
+ client,
144
+ target: { chatId: "chat-1", chatType: "direct" },
145
+ inboundMessageId: "inbound-1",
146
+ inboundForFinalReply: {
147
+ chatId: "chat-1",
148
+ senderId: "user-1",
149
+ senderNickName: "User 1",
150
+ bodyText: "hello",
151
+ },
152
+ store: store as never,
153
+ log: { info: vi.fn(), error: vi.fn() },
154
+ });
155
+
156
+ await (hooks.onReplyStart as (() => Promise<void>) | undefined)?.();
157
+ if (chunks[0]) {
158
+ await created.replyOptions.onPartialReply?.({ text: chunks[0] });
159
+ }
160
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
161
+ { text: chunks.at(-1) ?? "" },
162
+ { kind: "final" },
163
+ );
164
+ await (hooks.onIdle as () => Promise<void>)();
165
+
166
+ return {
167
+ sent,
168
+ streamingMessageId: sent.find((entry) => entry.event === "message.created")?.payload.message_id,
169
+ };
170
+ }
4
171
 
5
172
  describe("openclaw-clawchat reply-dispatcher", () => {
6
- it("uses chat_id, not sender_id, as the consolidated streaming reply marker", async () => {
173
+ it("claims static outbound before send and uses the claimed payload message_id", async () => {
174
+ const store = {
175
+ claimMessageOnce: vi.fn(() => true),
176
+ insertMessage: vi.fn(),
177
+ };
178
+
179
+ const result = await runStaticReplyWithStore(store);
180
+ const messageId = store.claimMessageOnce.mock.calls[0]![0].messageId;
181
+
182
+ expect(messageId).toEqual(expect.any(String));
183
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
184
+ direction: "outbound",
185
+ kind: "message",
186
+ messageId,
187
+ }));
188
+ expect(result.sent[0]?.payload.message_id).toBe(messageId);
189
+ expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
190
+ });
191
+
192
+ it("does not send static outbound when the storage claim is duplicate or unavailable", async () => {
193
+ const duplicateStore = { claimMessageOnce: vi.fn(() => false), insertMessage: vi.fn() };
194
+ const unavailableStore = { claimMessageOnce: vi.fn(() => null), insertMessage: vi.fn() };
195
+
196
+ await expect(runStaticReplyWithStore(duplicateStore)).resolves.toMatchObject({ queuedFinal: false });
197
+ await expect(runStaticReplyWithStore(unavailableStore)).resolves.toMatchObject({ queuedFinal: false });
198
+ expect(duplicateStore.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
199
+ direction: "outbound",
200
+ kind: "message",
201
+ }));
202
+ });
203
+
204
+ it("claims static forwarded thinking before sending", async () => {
205
+ let hooks: Record<string, unknown> = {};
206
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
207
+ const sent: SentFrame[] = [];
208
+ const client = mockClient(sent);
209
+
210
+ createOpenclawClawlingReplyDispatcher({
211
+ cfg: {} as never,
212
+ runtime: runtimeWithHooks((next) => {
213
+ hooks = next;
214
+ }),
215
+ account: replyAccount({ replyMode: "static", forwardThinking: true }),
216
+ client,
217
+ target: { chatId: "chat-1", chatType: "direct" },
218
+ store: store as never,
219
+ log: { info: vi.fn(), error: vi.fn() },
220
+ });
221
+
222
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
223
+ { text: "thinking text", isReasoning: true },
224
+ );
225
+ const messageId = store.claimMessageOnce.mock.calls[0]?.[0].messageId;
226
+
227
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
228
+ kind: "message",
229
+ direction: "outbound",
230
+ eventType: "message.send",
231
+ messageId,
232
+ text: "thinking text",
233
+ }));
234
+ expect(sent[0]?.payload.message_id).toBe(messageId);
235
+ expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
236
+ kind: "thinking",
237
+ messageId,
238
+ text: "thinking text",
239
+ }));
240
+ });
241
+
242
+ it("does not send static forwarded thinking when the storage claim is duplicate or unavailable", async () => {
243
+ for (const claimResult of [false, null] as const) {
244
+ let hooks: Record<string, unknown> = {};
245
+ const store = { claimMessageOnce: vi.fn(() => claimResult), insertMessage: vi.fn() };
246
+ const sent: SentFrame[] = [];
247
+ const client = mockClient(sent);
248
+
249
+ createOpenclawClawlingReplyDispatcher({
250
+ cfg: {} as never,
251
+ runtime: runtimeWithHooks((next) => {
252
+ hooks = next;
253
+ }),
254
+ account: replyAccount({ replyMode: "static", forwardThinking: true }),
255
+ client,
256
+ target: { chatId: "chat-1", chatType: "direct" },
257
+ store: store as never,
258
+ log: { info: vi.fn(), error: vi.fn() },
259
+ });
260
+
261
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
262
+ { text: "thinking text", isReasoning: true },
263
+ );
264
+
265
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
266
+ kind: "message",
267
+ direction: "outbound",
268
+ }));
269
+ expect(sent).toHaveLength(0);
270
+ expect(store.insertMessage).not.toHaveBeenCalled();
271
+ }
272
+ });
273
+
274
+ it("claims a streaming outbound message once and updates the row on final reply", async () => {
275
+ const store = {
276
+ claimMessageOnce: vi.fn(() => true),
277
+ updateMessageByIdentity: vi.fn(),
278
+ insertMessage: vi.fn(),
279
+ };
280
+
281
+ const result = await runStreamingReplyWithStore(store, ["hello", "hello final"]);
282
+
283
+ expect(result.streamingMessageId).toEqual(expect.any(String));
284
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
285
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
286
+ direction: "outbound",
287
+ eventType: "message.created",
288
+ messageId: result.streamingMessageId,
289
+ }));
290
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
291
+ direction: "outbound",
292
+ eventType: "message.reply",
293
+ messageId: result.streamingMessageId,
294
+ text: "hello final",
295
+ }));
296
+ });
297
+
298
+ it("queues the consolidated streaming final reply while disconnected and flushes it after reconnect", async () => {
299
+ let hooks: Record<string, unknown> = {};
300
+ const sent: SentFrame[] = [];
301
+ const client = mockClient(sent, { transportState: "closed", state: "reconnecting" });
302
+ const store = {
303
+ claimMessageOnce: vi.fn(() => true),
304
+ updateMessageByIdentity: vi.fn(),
305
+ insertMessage: vi.fn(),
306
+ };
307
+
308
+ createOpenclawClawlingReplyDispatcher({
309
+ cfg: {} as never,
310
+ runtime: runtimeWithHooks((next) => {
311
+ hooks = next;
312
+ }),
313
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
314
+ client,
315
+ target: { chatId: "chat-1", chatType: "direct" },
316
+ inboundMessageId: "inbound-1",
317
+ inboundForFinalReply: {
318
+ chatId: "chat-1",
319
+ senderId: "user-1",
320
+ senderNickName: "User 1",
321
+ bodyText: "hello",
322
+ },
323
+ store: store as never,
324
+ log: { info: vi.fn(), error: vi.fn() },
325
+ });
326
+
327
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
328
+ { text: "final answer" },
329
+ { kind: "final" },
330
+ );
331
+ const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
332
+ expect(streamingMessageId).toEqual(expect.any(String));
333
+
334
+ const idle = (hooks.onIdle as () => Promise<void>)();
335
+ await new Promise((resolve) => setTimeout(resolve, 0));
336
+
337
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
338
+ expect(sent.filter((entry) => entry.event === "message.reply")).toHaveLength(0);
339
+
340
+ client.setTransportState("open");
341
+ client.setState("connected");
342
+ flushAlignedOutboundQueue(client);
343
+ await idle;
344
+
345
+ const finalReply = sent.find((entry) => entry.event === "message.reply");
346
+ expect(finalReply?.payload.message_id).toBe(streamingMessageId);
347
+ expect(finalReply?.payload).toMatchObject({
348
+ message: {
349
+ body: { fragments: [{ kind: "text", text: "final answer" }] },
350
+ context: {
351
+ reply: {
352
+ reply_to_msg_id: "inbound-1",
353
+ reply_preview: {
354
+ id: "user-1",
355
+ nick_name: "User 1",
356
+ fragments: [{ kind: "text", text: "hello" }],
357
+ },
358
+ },
359
+ },
360
+ },
361
+ });
362
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
363
+ eventType: "message.reply",
364
+ messageId: streamingMessageId,
365
+ text: "final answer",
366
+ }));
367
+ });
368
+
369
+ it("requeues only the consolidated streaming final reply after disconnect before ack", async () => {
370
+ let hooks: Record<string, unknown> = {};
371
+ const sent: SentFrame[] = [];
372
+ const client = mockClient(sent, { autoAck: false });
373
+ const store = {
374
+ claimMessageOnce: vi.fn(() => true),
375
+ updateMessageByIdentity: vi.fn(),
376
+ insertMessage: vi.fn(),
377
+ };
378
+
379
+ createOpenclawClawlingReplyDispatcher({
380
+ cfg: {} as never,
381
+ runtime: runtimeWithHooks((next) => {
382
+ hooks = next;
383
+ }),
384
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
385
+ client,
386
+ target: { chatId: "chat-1", chatType: "direct" },
387
+ inboundMessageId: "inbound-1",
388
+ inboundForFinalReply: {
389
+ chatId: "chat-1",
390
+ senderId: "user-1",
391
+ senderNickName: "User 1",
392
+ bodyText: "hello",
393
+ },
394
+ store,
395
+ log: { info: vi.fn(), error: vi.fn() },
396
+ });
397
+
398
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
399
+ { text: "final answer" },
400
+ { kind: "final" },
401
+ );
402
+
403
+ const idle = (hooks.onIdle as () => Promise<void>)();
404
+ await new Promise((resolve) => setTimeout(resolve, 0));
405
+
406
+ const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
407
+ const firstFinalReply = sent.find((entry) => entry.event === "message.reply");
408
+ expect(streamingMessageId).toEqual(expect.any(String));
409
+ expect(firstFinalReply?.payload.message_id).toBe(streamingMessageId);
410
+ expect(firstFinalReply?.trace_id).toEqual(expect.any(String));
411
+
412
+ const lifecycleFramesBeforeReconnect = sent.filter((entry) =>
413
+ entry.event === "message.created" || entry.event === "message.add" || entry.event === "message.done"
414
+ );
415
+ const lifecycleCountsBeforeReconnect = {
416
+ created: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.created").length,
417
+ add: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.add").length,
418
+ done: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.done").length,
419
+ };
420
+ expect(lifecycleCountsBeforeReconnect.created).toBe(1);
421
+ expect(lifecycleCountsBeforeReconnect.add).toBeGreaterThanOrEqual(1);
422
+ expect(lifecycleCountsBeforeReconnect.done).toBe(1);
423
+ expect(lifecycleFramesBeforeReconnect.every((entry) => entry.payload.message_id === streamingMessageId)).toBe(true);
424
+
425
+ client.setTransportState("closed");
426
+ client.emit("close", { code: 1006, reason: "network lost" });
427
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
428
+
429
+ client.setTransportState("open");
430
+ client.setState("connected");
431
+ flushAlignedOutboundQueue(client);
432
+
433
+ expect({
434
+ created: sent.filter((entry) => entry.event === "message.created").length,
435
+ add: sent.filter((entry) => entry.event === "message.add").length,
436
+ done: sent.filter((entry) => entry.event === "message.done").length,
437
+ }).toEqual(lifecycleCountsBeforeReconnect);
438
+
439
+ const finalReplies = sent.filter((entry) => entry.event === "message.reply");
440
+ expect(finalReplies).toHaveLength(2);
441
+ expect(finalReplies[1]?.trace_id).toBe(firstFinalReply?.trace_id);
442
+ expect(finalReplies[1]?.payload.message_id).toBe(streamingMessageId);
443
+
444
+ client.emit("raw", {
445
+ version: "2",
446
+ event: "message.ack",
447
+ trace_id: firstFinalReply?.trace_id,
448
+ emitted_at: Date.now(),
449
+ payload: { message_id: streamingMessageId, accepted_at: 5678 },
450
+ });
451
+
452
+ await idle;
453
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
454
+ eventType: "message.reply",
455
+ messageId: streamingMessageId,
456
+ text: "final answer",
457
+ }));
458
+ });
459
+
460
+ it("keeps thinking rows separate from outbound message claims", async () => {
461
+ let hooks: Record<string, unknown> = {};
462
+ const store = {
463
+ claimMessageOnce: vi.fn(() => true),
464
+ updateMessageByIdentity: vi.fn(),
465
+ insertMessage: vi.fn(),
466
+ };
467
+ const sent: SentFrame[] = [];
468
+ const client = mockClient(sent);
469
+ const created = createOpenclawClawlingReplyDispatcher({
470
+ cfg: {} as never,
471
+ runtime: runtimeWithHooks((next) => {
472
+ hooks = next;
473
+ }),
474
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
475
+ client,
476
+ target: { chatId: "chat-1", chatType: "direct" },
477
+ inboundMessageId: "inbound-1",
478
+ inboundForFinalReply: {
479
+ chatId: "chat-1",
480
+ senderId: "user-1",
481
+ senderNickName: "User 1",
482
+ bodyText: "hello",
483
+ },
484
+ store: store as never,
485
+ log: { info: vi.fn(), error: vi.fn() },
486
+ });
487
+
488
+ await created.replyOptions.onReasoningStream?.({ text: "thinking out loud" });
489
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
490
+ { text: "final answer" },
491
+ { kind: "final" },
492
+ );
493
+ await (hooks.onIdle as () => Promise<void>)();
494
+ const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
495
+
496
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
497
+ expect(store.insertMessage).toHaveBeenCalledTimes(1);
498
+ expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
499
+ kind: "thinking",
500
+ messageId,
501
+ text: "thinking out loud",
502
+ }));
503
+ });
504
+
505
+ it("does not append a static message row after the pre-send claim", async () => {
506
+ let hooks: Record<string, unknown> = {};
507
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
508
+ const client = mockClient();
509
+
510
+ createOpenclawClawlingReplyDispatcher({
511
+ cfg: {} as never,
512
+ runtime: runtimeWithHooks((next) => {
513
+ hooks = next;
514
+ }),
515
+ account: replyAccount(),
516
+ client,
517
+ target: { chatId: "chat-1", chatType: "direct" },
518
+ store,
519
+ log: { info: vi.fn(), error: vi.fn() },
520
+ });
521
+
522
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
523
+ { text: "static reply" },
524
+ { kind: "final" },
525
+ );
526
+
527
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
528
+ kind: "message",
529
+ direction: "outbound",
530
+ eventType: "message.send",
531
+ text: "static reply",
532
+ }));
533
+ expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
534
+ });
535
+
536
+ it("persists streaming final outbound message and thinking with the same final message id", async () => {
537
+ let hooks: Record<string, unknown> = {};
538
+ const store = {
539
+ claimMessageOnce: vi.fn(() => true),
540
+ updateMessageByIdentity: vi.fn(),
541
+ insertMessage: vi.fn(),
542
+ };
543
+ const sent: SentFrame[] = [];
544
+ const client = mockClient(sent);
545
+
546
+ const created = createOpenclawClawlingReplyDispatcher({
547
+ cfg: {} as never,
548
+ runtime: runtimeWithHooks((next) => {
549
+ hooks = next;
550
+ }),
551
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
552
+ client,
553
+ target: { chatId: "chat-1", chatType: "direct" },
554
+ inboundMessageId: "inbound-1",
555
+ inboundForFinalReply: {
556
+ chatId: "chat-1",
557
+ senderId: "user-1",
558
+ senderNickName: "User 1",
559
+ bodyText: "hello",
560
+ },
561
+ store,
562
+ log: { info: vi.fn(), error: vi.fn() },
563
+ });
564
+
565
+ await created.replyOptions.onReasoningStream?.({
566
+ text: "reasoning text",
567
+ });
568
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
569
+ { text: "final answer" },
570
+ { kind: "final" },
571
+ );
572
+ await (hooks.onIdle as () => Promise<void>)();
573
+
574
+ expect(sent.map((entry) => entry.event)).toEqual([
575
+ "message.created",
576
+ "message.add",
577
+ "message.add",
578
+ "message.done",
579
+ "message.reply",
580
+ ]);
581
+ const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
582
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
583
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
584
+ platform: "openclaw",
585
+ accountId: "default",
586
+ kind: "message",
587
+ direction: "outbound",
588
+ eventType: "message.created",
589
+ chatId: "chat-1",
590
+ messageId,
591
+ }));
592
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
593
+ kind: "message",
594
+ direction: "outbound",
595
+ eventType: "message.reply",
596
+ messageId,
597
+ text: "final answer",
598
+ }));
599
+ expect(store.insertMessage).toHaveBeenCalledTimes(1);
600
+ const thinkingRow = store.insertMessage.mock.calls[0]![0];
601
+ expect(thinkingRow).toMatchObject({
602
+ platform: "openclaw",
603
+ accountId: "default",
604
+ kind: "thinking",
605
+ direction: "outbound",
606
+ eventType: "message.send",
607
+ chatId: "chat-1",
608
+ text: "reasoning text",
609
+ });
610
+ expect(thinkingRow.messageId).toBe(messageId);
611
+ expect(messageId).toEqual(expect.any(String));
612
+ });
613
+
614
+ it("uses the inbound sender id in consolidated streaming reply previews", async () => {
7
615
  let hooks:
8
616
  | {
9
617
  deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
@@ -11,18 +619,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
11
619
  }
12
620
  | undefined;
13
621
  const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
14
- const client = {
15
- opts: {
16
- transport: {
17
- send: (data: string) => {
18
- const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
19
- sent.push({ event: env.event, payload: env.payload });
20
- },
21
- },
22
- traceIdFactory: () => "trace-chat-marker",
23
- },
24
- typing: vi.fn(),
25
- } as unknown as ClawlingChatClient;
622
+ const client = mockClient(sent);
623
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
26
624
 
27
625
  createOpenclawClawlingReplyDispatcher({
28
626
  cfg: {} as never,
@@ -58,6 +656,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
58
656
  senderNickName: "User 1",
59
657
  bodyText: "hello",
60
658
  },
659
+ store: store as never,
61
660
  log: { info: vi.fn(), error: vi.fn() },
62
661
  });
63
662
 
@@ -70,7 +669,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
70
669
  context: {
71
670
  reply: {
72
671
  reply_preview: {
73
- id: "chat-1",
672
+ id: "user-1",
74
673
  nick_name: "User 1",
75
674
  },
76
675
  },
@@ -79,7 +678,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
79
678
  });
80
679
  });
81
680
 
82
- it("emits message.failed in stream mode even if execution errors before any stream chunk", async () => {
681
+ it("emits sanitized message.failed in stream mode even if execution errors before any stream chunk", async () => {
83
682
  let hooks:
84
683
  | {
85
684
  deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
@@ -88,18 +687,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
88
687
  }
89
688
  | undefined;
90
689
  const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
91
- const client = {
92
- opts: {
93
- transport: {
94
- send: (data: string) => {
95
- const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
96
- sent.push({ event: env.event, payload: env.payload });
97
- },
98
- },
99
- traceIdFactory: () => "trace-1",
100
- },
101
- typing: vi.fn(),
102
- } as unknown as ClawlingChatClient;
690
+ const client = mockClient(sent);
691
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
103
692
 
104
693
  createOpenclawClawlingReplyDispatcher({
105
694
  cfg: {} as never,
@@ -135,7 +724,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
135
724
 
136
725
  expect(sent).toHaveLength(1);
137
726
  expect(sent[0]!.event).toBe("message.failed");
138
- expect(sent[0]!.payload.reason).toBe("Error: boom");
727
+ expect(sent[0]!.payload).not.toHaveProperty("reason");
728
+ expect(sent[0]!.payload.fragments).toEqual([
729
+ { kind: "text", text: "OpenClaw could not complete this reply." },
730
+ ]);
731
+ expect(JSON.stringify(sent[0]!.payload)).not.toContain("boom");
139
732
  expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
140
733
  ["chat-1", false],
141
734
  ]);
@@ -150,18 +743,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
150
743
  }
151
744
  | undefined;
152
745
  const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
153
- const client = {
154
- opts: {
155
- transport: {
156
- send: (data: string) => {
157
- const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
158
- sent.push({ event: env.event, payload: env.payload });
159
- },
160
- },
161
- traceIdFactory: () => "trace-2",
162
- },
163
- typing: vi.fn(),
164
- } as unknown as ClawlingChatClient;
746
+ const client = mockClient(sent);
747
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
165
748
 
166
749
  createOpenclawClawlingReplyDispatcher({
167
750
  cfg: {} as never,
@@ -196,6 +779,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
196
779
  senderNickName: "User 1",
197
780
  bodyText: "hello",
198
781
  },
782
+ store: store as never,
199
783
  log: { info: vi.fn(), error: vi.fn() },
200
784
  });
201
785
 
@@ -212,20 +796,15 @@ describe("openclaw-clawchat reply-dispatcher", () => {
212
796
  expect(sent.find((entry) => entry.event === "message.done")).toBeUndefined();
213
797
  });
214
798
 
215
- it("sends static error text when non-streaming reply execution fails", async () => {
799
+ it("does not send static error text when non-streaming reply execution fails", async () => {
216
800
  let hooks:
217
801
  | {
218
802
  onError?: (error: unknown, info: { kind: string }) => void;
219
803
  }
220
804
  | undefined;
221
- const client = {
222
- sendMessage: vi.fn().mockResolvedValue({
223
- payload: { message_id: "server-m1", accepted_at: 1234 },
224
- }),
225
- replyMessage: vi.fn(),
226
- typing: vi.fn(),
227
- } as unknown as ClawlingChatClient;
805
+ const client = mockClient();
228
806
 
807
+ const logError = vi.fn();
229
808
  createOpenclawClawlingReplyDispatcher({
230
809
  cfg: {} as never,
231
810
  runtime: {
@@ -253,35 +832,27 @@ describe("openclaw-clawchat reply-dispatcher", () => {
253
832
  } as never,
254
833
  client,
255
834
  target: { chatId: "chat-1", chatType: "direct" },
256
- log: { info: vi.fn(), error: vi.fn() },
835
+ log: { info: vi.fn(), error: logError },
257
836
  });
258
837
 
259
838
  hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
260
839
  await new Promise((resolve) => setTimeout(resolve, 0));
261
840
 
262
- expect(client.sendMessage).toHaveBeenCalledWith(
263
- expect.objectContaining({
264
- chat_id: "chat-1",
265
- body: { fragments: [{ kind: "text", text: "Error: boom" }] },
266
- }),
841
+ expect(client.sent).toHaveLength(0);
842
+ expect(logError).toHaveBeenCalledWith(
843
+ expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
267
844
  );
268
- expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
269
- expect(client.replyMessage).not.toHaveBeenCalled();
270
845
  });
271
846
 
272
- it("strips delivery retry wrapper text before sending non-streaming errors", async () => {
847
+ it("does not attempt fallback static sends after non-streaming reply failures", async () => {
273
848
  let hooks:
274
849
  | {
275
850
  onError?: (error: unknown, info: { kind: string }) => void;
276
851
  }
277
852
  | undefined;
278
- const client = {
279
- sendMessage: vi.fn().mockResolvedValue({
280
- payload: { message_id: "server-m1", accepted_at: 1234 },
281
- }),
282
- replyMessage: vi.fn(),
283
- typing: vi.fn(),
284
- } as unknown as ClawlingChatClient;
853
+ const logError = vi.fn();
854
+ const client = mockClient();
855
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
285
856
 
286
857
  createOpenclawClawlingReplyDispatcher({
287
858
  cfg: {} as never,
@@ -310,7 +881,57 @@ describe("openclaw-clawchat reply-dispatcher", () => {
310
881
  } as never,
311
882
  client,
312
883
  target: { chatId: "chat-1", chatType: "direct" },
313
- log: { info: vi.fn(), error: vi.fn() },
884
+ log: { info: vi.fn(), error: logError },
885
+ });
886
+
887
+ hooks?.onError?.(new Error("final delivery failed"), { kind: "final" });
888
+ await new Promise((resolve) => setTimeout(resolve, 0));
889
+
890
+ expect(client.sent).toHaveLength(0);
891
+ expect(logError).toHaveBeenCalledWith(
892
+ expect.stringContaining(
893
+ "openclaw-clawchat final reply failed: Error: final delivery failed",
894
+ ),
895
+ );
896
+ });
897
+
898
+ it("strips delivery retry wrapper text before logging non-streaming errors", async () => {
899
+ let hooks:
900
+ | {
901
+ onError?: (error: unknown, info: { kind: string }) => void;
902
+ }
903
+ | undefined;
904
+ const client = mockClient();
905
+
906
+ const logError = vi.fn();
907
+ createOpenclawClawlingReplyDispatcher({
908
+ cfg: {} as never,
909
+ runtime: {
910
+ channel: {
911
+ reply: {
912
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
913
+ createReplyDispatcherWithTyping: vi.fn((options) => {
914
+ hooks = options;
915
+ return {
916
+ dispatcher: {},
917
+ replyOptions: {},
918
+ markDispatchIdle: vi.fn(),
919
+ };
920
+ }),
921
+ },
922
+ },
923
+ } as never,
924
+ account: {
925
+ accountId: "default",
926
+ userId: "agent-1",
927
+ replyMode: "static",
928
+ forwardThinking: true,
929
+ forwardToolCalls: false,
930
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
931
+ } as never,
932
+ client,
933
+ target: { chatId: "chat-1", chatType: "direct" },
934
+ log: { info: vi.fn(), error: logError },
314
935
  });
315
936
 
316
937
  hooks?.onError?.(
@@ -319,13 +940,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
319
940
  );
320
941
  await new Promise((resolve) => setTimeout(resolve, 0));
321
942
 
322
- expect(client.sendMessage).toHaveBeenCalledWith(
323
- expect.objectContaining({
324
- chat_id: "chat-1",
325
- body: { fragments: [{ kind: "text", text: "Error: boom" }] },
326
- }),
943
+ expect(client.sent).toHaveLength(0);
944
+ expect(logError).toHaveBeenCalledWith(
945
+ expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
327
946
  );
328
- expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
947
+ expect(logError).not.toHaveBeenCalledWith(expect.stringContaining("Retry failed"));
329
948
  });
330
949
 
331
950
  it("emits approval rich fragments with fallback_text when rich interactions are enabled", async () => {
@@ -344,14 +963,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
344
963
  }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
345
964
  }
346
965
  | undefined;
347
- const client = {
348
- sendMessage: vi.fn().mockResolvedValue({
349
- payload: { message_id: "server-m1", accepted_at: 1234 },
350
- trace_id: "trace-rich",
351
- }),
352
- replyMessage: vi.fn(),
353
- typing: vi.fn(),
354
- } as unknown as ClawlingChatClient;
966
+ const client = mockClient();
967
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
355
968
 
356
969
  createOpenclawClawlingReplyDispatcher({
357
970
  cfg: {} as never,
@@ -374,9 +987,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
374
987
  forwardToolCalls: false,
375
988
  richInteractions: true,
376
989
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
990
+ ack: { timeout: 10000, autoResendOnTimeout: false },
377
991
  } as never,
378
992
  client,
379
993
  target: { chatId: "chat-1", chatType: "direct" },
994
+ store: store as never,
380
995
  log: { info: vi.fn(), error: vi.fn() },
381
996
  });
382
997
 
@@ -401,8 +1016,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
401
1016
  { kind: "final" },
402
1017
  );
403
1018
 
404
- expect(client.sendMessage).toHaveBeenCalledWith(
405
- expect.objectContaining({
1019
+ expect(client.sent[0]?.payload).toMatchObject({
1020
+ message: {
406
1021
  body: {
407
1022
  fragments: [
408
1023
  {
@@ -417,8 +1032,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
417
1032
  },
418
1033
  ],
419
1034
  },
420
- }),
421
- );
1035
+ },
1036
+ });
422
1037
  });
423
1038
 
424
1039
  it("sends plain fallback text for presentations when rich interactions are disabled", async () => {
@@ -433,14 +1048,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
433
1048
  }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
434
1049
  }
435
1050
  | undefined;
436
- const client = {
437
- sendMessage: vi.fn().mockResolvedValue({
438
- payload: { message_id: "server-m1", accepted_at: 1234 },
439
- trace_id: "trace-fallback",
440
- }),
441
- replyMessage: vi.fn(),
442
- typing: vi.fn(),
443
- } as unknown as ClawlingChatClient;
1051
+ const client = mockClient();
1052
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
444
1053
 
445
1054
  createOpenclawClawlingReplyDispatcher({
446
1055
  cfg: {} as never,
@@ -463,9 +1072,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
463
1072
  forwardToolCalls: false,
464
1073
  richInteractions: false,
465
1074
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1075
+ ack: { timeout: 10000, autoResendOnTimeout: false },
466
1076
  } as never,
467
1077
  client,
468
1078
  target: { chatId: "chat-1", chatType: "direct" },
1079
+ store: store as never,
469
1080
  log: { info: vi.fn(), error: vi.fn() },
470
1081
  });
471
1082
 
@@ -480,13 +1091,115 @@ describe("openclaw-clawchat reply-dispatcher", () => {
480
1091
  { kind: "final" },
481
1092
  );
482
1093
 
483
- expect(client.sendMessage).toHaveBeenCalledWith(
484
- expect.objectContaining({
1094
+ expect(client.sent[0]?.payload).toMatchObject({
1095
+ message: {
485
1096
  body: {
486
1097
  fragments: [{ kind: "text", text: expect.stringContaining("Delete /tmp/example.txt?") }],
487
1098
  },
488
- }),
1099
+ },
1100
+ });
1101
+ });
1102
+
1103
+ it("prefers mediaUrls over legacy mediaUrl so one image is not sent twice", async () => {
1104
+ let hooks:
1105
+ | {
1106
+ deliver?: (payload: {
1107
+ text?: string;
1108
+ mediaUrl?: string;
1109
+ mediaUrls?: string[];
1110
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
1111
+ }
1112
+ | undefined;
1113
+ const loadWebMedia = vi.fn(async (url: string) => ({
1114
+ buffer: Buffer.from(`bytes:${url}`),
1115
+ contentType: "image/png",
1116
+ fileName: "image.png",
1117
+ }));
1118
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1119
+ new Response(
1120
+ JSON.stringify({
1121
+ code: 0,
1122
+ msg: "ok",
1123
+ data: {
1124
+ kind: "image",
1125
+ url: "https://cdn/uploaded.png",
1126
+ name: "uploaded.png",
1127
+ size: 12,
1128
+ mime: "image/png",
1129
+ },
1130
+ }),
1131
+ { status: 200, headers: { "content-type": "application/json" } },
1132
+ ),
489
1133
  );
1134
+ const client = mockClient();
1135
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
1136
+
1137
+ try {
1138
+ createOpenclawClawlingReplyDispatcher({
1139
+ cfg: {} as never,
1140
+ runtime: {
1141
+ media: { loadWebMedia },
1142
+ channel: {
1143
+ reply: {
1144
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1145
+ createReplyDispatcherWithTyping: vi.fn((options) => {
1146
+ hooks = options;
1147
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
1148
+ }),
1149
+ },
1150
+ },
1151
+ } as never,
1152
+ account: {
1153
+ accountId: "default",
1154
+ baseUrl: "https://api.example.com",
1155
+ token: "tk",
1156
+ userId: "agent-1",
1157
+ replyMode: "static",
1158
+ forwardThinking: true,
1159
+ forwardToolCalls: false,
1160
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1161
+ ack: { timeout: 10000, autoResendOnTimeout: false },
1162
+ } as never,
1163
+ client,
1164
+ target: { chatId: "chat-1", chatType: "direct" },
1165
+ store: store as never,
1166
+ log: { info: vi.fn(), error: vi.fn() },
1167
+ });
1168
+
1169
+ await hooks?.deliver?.(
1170
+ {
1171
+ text: "look",
1172
+ mediaUrl: "https://cdn/legacy.png",
1173
+ mediaUrls: ["https://cdn/image.png"],
1174
+ },
1175
+ { kind: "final" },
1176
+ );
1177
+
1178
+ expect(loadWebMedia).toHaveBeenCalledTimes(1);
1179
+ expect(loadWebMedia).toHaveBeenCalledWith("https://cdn/image.png", expect.any(Object));
1180
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1181
+ expect(client.sent[0]).toMatchObject({
1182
+ chat_id: "chat-1",
1183
+ payload: {
1184
+ message: {
1185
+ body: {
1186
+ fragments: [
1187
+ { kind: "text", text: "look" },
1188
+ {
1189
+ kind: "image",
1190
+ url: "https://cdn/uploaded.png",
1191
+ mime: "image/png",
1192
+ size: 12,
1193
+ name: "uploaded.png",
1194
+ },
1195
+ ],
1196
+ },
1197
+ },
1198
+ },
1199
+ });
1200
+ } finally {
1201
+ fetchMock.mockRestore();
1202
+ }
490
1203
  });
491
1204
 
492
1205
  it("includes rich interaction fragments in the consolidated streaming final reply", async () => {
@@ -507,18 +1220,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
507
1220
  }
508
1221
  | undefined;
509
1222
  const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
510
- const client = {
511
- opts: {
512
- transport: {
513
- send: (data: string) => {
514
- const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
515
- sent.push({ event: env.event, payload: env.payload });
516
- },
517
- },
518
- traceIdFactory: () => "trace-stream-rich",
519
- },
520
- typing: vi.fn(),
521
- } as unknown as ClawlingChatClient;
1223
+ const client = mockClient(sent);
1224
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
522
1225
 
523
1226
  createOpenclawClawlingReplyDispatcher({
524
1227
  cfg: {} as never,
@@ -551,6 +1254,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
551
1254
  senderNickName: "User 1",
552
1255
  bodyText: "hello",
553
1256
  },
1257
+ store: store as never,
554
1258
  log: { info: vi.fn(), error: vi.fn() },
555
1259
  });
556
1260
 
@@ -601,14 +1305,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
601
1305
  }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
602
1306
  }
603
1307
  | undefined;
604
- const client = {
605
- sendMessage: vi.fn().mockResolvedValue({
606
- payload: { message_id: "server-m1", accepted_at: 1234 },
607
- trace_id: "trace-block-rich",
608
- }),
609
- replyMessage: vi.fn(),
610
- typing: vi.fn(),
611
- } as unknown as ClawlingChatClient;
1308
+ const client = mockClient();
1309
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
612
1310
 
613
1311
  createOpenclawClawlingReplyDispatcher({
614
1312
  cfg: {} as never,
@@ -631,9 +1329,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
631
1329
  forwardToolCalls: false,
632
1330
  richInteractions: true,
633
1331
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1332
+ ack: { timeout: 10000, autoResendOnTimeout: false },
634
1333
  } as never,
635
1334
  client,
636
1335
  target: { chatId: "chat-1", chatType: "direct" },
1336
+ store: store as never,
637
1337
  log: { info: vi.fn(), error: vi.fn() },
638
1338
  });
639
1339
 
@@ -650,8 +1350,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
650
1350
  { kind: "block" },
651
1351
  );
652
1352
 
653
- expect(client.sendMessage).toHaveBeenCalledWith(
654
- expect.objectContaining({
1353
+ expect(client.sent[0]?.payload).toMatchObject({
1354
+ message: {
655
1355
  body: {
656
1356
  fragments: [
657
1357
  {
@@ -663,7 +1363,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
663
1363
  },
664
1364
  ],
665
1365
  },
666
- }),
667
- );
1366
+ },
1367
+ });
668
1368
  });
669
1369
  });