@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,8 +1,656 @@
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", () => {
173
+ it("forces OpenClaw block streaming delivery for ClawChat stream mode", () => {
174
+ let hooks: Record<string, unknown> = {};
175
+ const sent: SentFrame[] = [];
176
+ const client = mockClient(sent);
177
+
178
+ const created = createOpenclawClawlingReplyDispatcher({
179
+ cfg: {} as never,
180
+ runtime: runtimeWithHooks((next) => {
181
+ hooks = next;
182
+ }),
183
+ account: replyAccount({ replyMode: "stream" }),
184
+ client,
185
+ target: { chatId: "chat-1", chatType: "direct" },
186
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
187
+ log: { info: vi.fn(), error: vi.fn() },
188
+ });
189
+
190
+ expect(hooks).toBeTruthy();
191
+ expect((created.replyOptions as Record<string, unknown>).sourceReplyDeliveryMode).toBe("automatic");
192
+ expect((created.replyOptions as Record<string, unknown>).disableBlockStreaming).toBe(false);
193
+ expect(typeof created.replyOptions.onPartialReply).toBe("function");
194
+ });
195
+
196
+ it("does not force OpenClaw block streaming delivery for explicit static mode", () => {
197
+ const client = mockClient();
198
+
199
+ const created = createOpenclawClawlingReplyDispatcher({
200
+ cfg: {} as never,
201
+ runtime: runtimeWithHooks(() => {}),
202
+ account: replyAccount({ replyMode: "static" }),
203
+ client,
204
+ target: { chatId: "chat-1", chatType: "direct" },
205
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
206
+ log: { info: vi.fn(), error: vi.fn() },
207
+ });
208
+
209
+ expect((created.replyOptions as Record<string, unknown>).disableBlockStreaming).toBeUndefined();
210
+ expect(created.replyOptions.onPartialReply).toBeUndefined();
211
+ });
212
+
213
+ it("claims static outbound before send and uses the claimed payload message_id", async () => {
214
+ const store = {
215
+ claimMessageOnce: vi.fn(() => true),
216
+ insertMessage: vi.fn(),
217
+ };
218
+
219
+ const result = await runStaticReplyWithStore(store);
220
+ const messageId = store.claimMessageOnce.mock.calls[0]![0].messageId;
221
+
222
+ expect(messageId).toEqual(expect.any(String));
223
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
224
+ direction: "outbound",
225
+ kind: "message",
226
+ messageId,
227
+ }));
228
+ expect(result.sent[0]?.payload.message_id).toBe(messageId);
229
+ expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
230
+ });
231
+
232
+ it("does not send static outbound when the storage claim is duplicate or unavailable", async () => {
233
+ const duplicateStore = { claimMessageOnce: vi.fn(() => false), insertMessage: vi.fn() };
234
+ const unavailableStore = { claimMessageOnce: vi.fn(() => null), insertMessage: vi.fn() };
235
+
236
+ await expect(runStaticReplyWithStore(duplicateStore)).resolves.toMatchObject({ queuedFinal: false });
237
+ await expect(runStaticReplyWithStore(unavailableStore)).resolves.toMatchObject({ queuedFinal: false });
238
+ expect(duplicateStore.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
239
+ direction: "outbound",
240
+ kind: "message",
241
+ }));
242
+ });
243
+
244
+ it("claims static forwarded thinking before sending", async () => {
245
+ let hooks: Record<string, unknown> = {};
246
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
247
+ const sent: SentFrame[] = [];
248
+ const client = mockClient(sent);
249
+
250
+ createOpenclawClawlingReplyDispatcher({
251
+ cfg: {} as never,
252
+ runtime: runtimeWithHooks((next) => {
253
+ hooks = next;
254
+ }),
255
+ account: replyAccount({ replyMode: "static", forwardThinking: true }),
256
+ client,
257
+ target: { chatId: "chat-1", chatType: "direct" },
258
+ store: store as never,
259
+ log: { info: vi.fn(), error: vi.fn() },
260
+ });
261
+
262
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
263
+ { text: "thinking text", isReasoning: true },
264
+ );
265
+ const messageId = store.claimMessageOnce.mock.calls[0]?.[0].messageId;
266
+
267
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
268
+ kind: "message",
269
+ direction: "outbound",
270
+ eventType: "message.send",
271
+ messageId,
272
+ text: "thinking text",
273
+ }));
274
+ expect(sent[0]?.payload.message_id).toBe(messageId);
275
+ expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
276
+ kind: "thinking",
277
+ messageId,
278
+ text: "thinking text",
279
+ }));
280
+ });
281
+
282
+ it("does not send static forwarded thinking when the storage claim is duplicate or unavailable", async () => {
283
+ for (const claimResult of [false, null] as const) {
284
+ let hooks: Record<string, unknown> = {};
285
+ const store = { claimMessageOnce: vi.fn(() => claimResult), insertMessage: vi.fn() };
286
+ const sent: SentFrame[] = [];
287
+ const client = mockClient(sent);
288
+
289
+ createOpenclawClawlingReplyDispatcher({
290
+ cfg: {} as never,
291
+ runtime: runtimeWithHooks((next) => {
292
+ hooks = next;
293
+ }),
294
+ account: replyAccount({ replyMode: "static", forwardThinking: true }),
295
+ client,
296
+ target: { chatId: "chat-1", chatType: "direct" },
297
+ store: store as never,
298
+ log: { info: vi.fn(), error: vi.fn() },
299
+ });
300
+
301
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
302
+ { text: "thinking text", isReasoning: true },
303
+ );
304
+
305
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
306
+ kind: "message",
307
+ direction: "outbound",
308
+ }));
309
+ expect(sent).toHaveLength(0);
310
+ expect(store.insertMessage).not.toHaveBeenCalled();
311
+ }
312
+ });
313
+
314
+ it("claims a streaming outbound message once and updates the row on final reply", async () => {
315
+ const store = {
316
+ claimMessageOnce: vi.fn(() => true),
317
+ updateMessageByIdentity: vi.fn(),
318
+ insertMessage: vi.fn(),
319
+ };
320
+
321
+ const result = await runStreamingReplyWithStore(store, ["hello", "hello final"]);
322
+
323
+ expect(result.streamingMessageId).toEqual(expect.any(String));
324
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
325
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
326
+ direction: "outbound",
327
+ eventType: "message.created",
328
+ messageId: result.streamingMessageId,
329
+ }));
330
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
331
+ direction: "outbound",
332
+ eventType: "message.reply",
333
+ messageId: result.streamingMessageId,
334
+ text: "hello final",
335
+ }));
336
+ });
337
+
338
+ it("queues the consolidated streaming final reply while disconnected and flushes it after reconnect", async () => {
339
+ let hooks: Record<string, unknown> = {};
340
+ const sent: SentFrame[] = [];
341
+ const client = mockClient(sent, { transportState: "closed", state: "reconnecting" });
342
+ const store = {
343
+ claimMessageOnce: vi.fn(() => true),
344
+ updateMessageByIdentity: vi.fn(),
345
+ insertMessage: vi.fn(),
346
+ };
347
+
348
+ createOpenclawClawlingReplyDispatcher({
349
+ cfg: {} as never,
350
+ runtime: runtimeWithHooks((next) => {
351
+ hooks = next;
352
+ }),
353
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
354
+ client,
355
+ target: { chatId: "chat-1", chatType: "direct" },
356
+ inboundMessageId: "inbound-1",
357
+ inboundForFinalReply: {
358
+ chatId: "chat-1",
359
+ senderId: "user-1",
360
+ senderNickName: "User 1",
361
+ bodyText: "hello",
362
+ },
363
+ store: store as never,
364
+ log: { info: vi.fn(), error: vi.fn() },
365
+ });
366
+
367
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
368
+ { text: "final answer" },
369
+ { kind: "final" },
370
+ );
371
+ const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
372
+ expect(streamingMessageId).toEqual(expect.any(String));
373
+
374
+ const idle = (hooks.onIdle as () => Promise<void>)();
375
+ await new Promise((resolve) => setTimeout(resolve, 0));
376
+
377
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
378
+ expect(sent.filter((entry) => entry.event === "message.reply")).toHaveLength(0);
379
+
380
+ client.setTransportState("open");
381
+ client.setState("connected");
382
+ flushAlignedOutboundQueue(client);
383
+ await idle;
384
+
385
+ const finalReply = sent.find((entry) => entry.event === "message.reply");
386
+ expect(finalReply?.payload.message_id).toBe(streamingMessageId);
387
+ expect(finalReply?.payload).toMatchObject({
388
+ message: {
389
+ body: { fragments: [{ kind: "text", text: "final answer" }] },
390
+ context: {
391
+ reply: {
392
+ reply_to_msg_id: "inbound-1",
393
+ reply_preview: {
394
+ id: "user-1",
395
+ nick_name: "User 1",
396
+ fragments: [{ kind: "text", text: "hello" }],
397
+ },
398
+ },
399
+ },
400
+ },
401
+ });
402
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
403
+ eventType: "message.reply",
404
+ messageId: streamingMessageId,
405
+ text: "final answer",
406
+ }));
407
+ });
408
+
409
+ it("requeues only the consolidated streaming final reply after disconnect before ack", async () => {
410
+ let hooks: Record<string, unknown> = {};
411
+ const sent: SentFrame[] = [];
412
+ const client = mockClient(sent, { autoAck: false });
413
+ const store = {
414
+ claimMessageOnce: vi.fn(() => true),
415
+ updateMessageByIdentity: vi.fn(),
416
+ insertMessage: vi.fn(),
417
+ };
418
+
419
+ createOpenclawClawlingReplyDispatcher({
420
+ cfg: {} as never,
421
+ runtime: runtimeWithHooks((next) => {
422
+ hooks = next;
423
+ }),
424
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
425
+ client,
426
+ target: { chatId: "chat-1", chatType: "direct" },
427
+ inboundMessageId: "inbound-1",
428
+ inboundForFinalReply: {
429
+ chatId: "chat-1",
430
+ senderId: "user-1",
431
+ senderNickName: "User 1",
432
+ bodyText: "hello",
433
+ },
434
+ store,
435
+ log: { info: vi.fn(), error: vi.fn() },
436
+ });
437
+
438
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
439
+ { text: "final answer" },
440
+ { kind: "final" },
441
+ );
442
+
443
+ const idle = (hooks.onIdle as () => Promise<void>)();
444
+ await new Promise((resolve) => setTimeout(resolve, 0));
445
+
446
+ const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
447
+ const firstFinalReply = sent.find((entry) => entry.event === "message.reply");
448
+ expect(streamingMessageId).toEqual(expect.any(String));
449
+ expect(firstFinalReply?.payload.message_id).toBe(streamingMessageId);
450
+ expect(firstFinalReply?.trace_id).toEqual(expect.any(String));
451
+
452
+ const lifecycleFramesBeforeReconnect = sent.filter((entry) =>
453
+ entry.event === "message.created" || entry.event === "message.add" || entry.event === "message.done"
454
+ );
455
+ const lifecycleCountsBeforeReconnect = {
456
+ created: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.created").length,
457
+ add: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.add").length,
458
+ done: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.done").length,
459
+ };
460
+ expect(lifecycleCountsBeforeReconnect.created).toBe(1);
461
+ expect(lifecycleCountsBeforeReconnect.add).toBeGreaterThanOrEqual(1);
462
+ expect(lifecycleCountsBeforeReconnect.done).toBe(1);
463
+ expect(lifecycleFramesBeforeReconnect.every((entry) => entry.payload.message_id === streamingMessageId)).toBe(true);
464
+
465
+ client.setTransportState("closed");
466
+ client.emit("close", { code: 1006, reason: "network lost" });
467
+ expect(getAlignedOutboundQueueSize(client)).toBe(1);
468
+
469
+ client.setTransportState("open");
470
+ client.setState("connected");
471
+ flushAlignedOutboundQueue(client);
472
+
473
+ expect({
474
+ created: sent.filter((entry) => entry.event === "message.created").length,
475
+ add: sent.filter((entry) => entry.event === "message.add").length,
476
+ done: sent.filter((entry) => entry.event === "message.done").length,
477
+ }).toEqual(lifecycleCountsBeforeReconnect);
478
+
479
+ const finalReplies = sent.filter((entry) => entry.event === "message.reply");
480
+ expect(finalReplies).toHaveLength(2);
481
+ expect(finalReplies[1]?.trace_id).toBe(firstFinalReply?.trace_id);
482
+ expect(finalReplies[1]?.payload.message_id).toBe(streamingMessageId);
483
+
484
+ client.emit("raw", {
485
+ version: "2",
486
+ event: "message.ack",
487
+ trace_id: firstFinalReply?.trace_id,
488
+ emitted_at: Date.now(),
489
+ payload: { message_id: streamingMessageId, accepted_at: 5678 },
490
+ });
491
+
492
+ await idle;
493
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
494
+ eventType: "message.reply",
495
+ messageId: streamingMessageId,
496
+ text: "final answer",
497
+ }));
498
+ });
499
+
500
+ it("keeps thinking rows separate from outbound message claims", async () => {
501
+ let hooks: Record<string, unknown> = {};
502
+ const store = {
503
+ claimMessageOnce: vi.fn(() => true),
504
+ updateMessageByIdentity: vi.fn(),
505
+ insertMessage: vi.fn(),
506
+ };
507
+ const sent: SentFrame[] = [];
508
+ const client = mockClient(sent);
509
+ const created = createOpenclawClawlingReplyDispatcher({
510
+ cfg: {} as never,
511
+ runtime: runtimeWithHooks((next) => {
512
+ hooks = next;
513
+ }),
514
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
515
+ client,
516
+ target: { chatId: "chat-1", chatType: "direct" },
517
+ inboundMessageId: "inbound-1",
518
+ inboundForFinalReply: {
519
+ chatId: "chat-1",
520
+ senderId: "user-1",
521
+ senderNickName: "User 1",
522
+ bodyText: "hello",
523
+ },
524
+ store: store as never,
525
+ log: { info: vi.fn(), error: vi.fn() },
526
+ });
527
+
528
+ await created.replyOptions.onReasoningStream?.({ text: "thinking out loud" });
529
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
530
+ { text: "final answer" },
531
+ { kind: "final" },
532
+ );
533
+ await (hooks.onIdle as () => Promise<void>)();
534
+ const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
535
+
536
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
537
+ expect(store.insertMessage).toHaveBeenCalledTimes(1);
538
+ expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
539
+ kind: "thinking",
540
+ messageId,
541
+ text: "thinking out loud",
542
+ }));
543
+ });
544
+
545
+ it("does not append a static message row after the pre-send claim", async () => {
546
+ let hooks: Record<string, unknown> = {};
547
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
548
+ const client = mockClient();
549
+
550
+ createOpenclawClawlingReplyDispatcher({
551
+ cfg: {} as never,
552
+ runtime: runtimeWithHooks((next) => {
553
+ hooks = next;
554
+ }),
555
+ account: replyAccount(),
556
+ client,
557
+ target: { chatId: "chat-1", chatType: "direct" },
558
+ store,
559
+ log: { info: vi.fn(), error: vi.fn() },
560
+ });
561
+
562
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
563
+ { text: "static reply" },
564
+ { kind: "final" },
565
+ );
566
+
567
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
568
+ kind: "message",
569
+ direction: "outbound",
570
+ eventType: "message.send",
571
+ text: "static reply",
572
+ }));
573
+ expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
574
+ });
575
+
576
+ it("persists streaming final outbound message and thinking with the same final message id", async () => {
577
+ let hooks: Record<string, unknown> = {};
578
+ const store = {
579
+ claimMessageOnce: vi.fn(() => true),
580
+ updateMessageByIdentity: vi.fn(),
581
+ insertMessage: vi.fn(),
582
+ };
583
+ const sent: SentFrame[] = [];
584
+ const client = mockClient(sent);
585
+
586
+ const created = createOpenclawClawlingReplyDispatcher({
587
+ cfg: {} as never,
588
+ runtime: runtimeWithHooks((next) => {
589
+ hooks = next;
590
+ }),
591
+ account: replyAccount({ replyMode: "stream", forwardThinking: true }),
592
+ client,
593
+ target: { chatId: "chat-1", chatType: "direct" },
594
+ inboundMessageId: "inbound-1",
595
+ inboundForFinalReply: {
596
+ chatId: "chat-1",
597
+ senderId: "user-1",
598
+ senderNickName: "User 1",
599
+ bodyText: "hello",
600
+ },
601
+ store,
602
+ log: { info: vi.fn(), error: vi.fn() },
603
+ });
604
+
605
+ await created.replyOptions.onReasoningStream?.({
606
+ text: "reasoning text",
607
+ });
608
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
609
+ { text: "final answer" },
610
+ { kind: "final" },
611
+ );
612
+ await (hooks.onIdle as () => Promise<void>)();
613
+
614
+ expect(sent.map((entry) => entry.event)).toEqual([
615
+ "message.created",
616
+ "message.add",
617
+ "message.add",
618
+ "message.done",
619
+ "message.reply",
620
+ ]);
621
+ const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
622
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
623
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
624
+ platform: "openclaw",
625
+ accountId: "default",
626
+ kind: "message",
627
+ direction: "outbound",
628
+ eventType: "message.created",
629
+ chatId: "chat-1",
630
+ messageId,
631
+ }));
632
+ expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
633
+ kind: "message",
634
+ direction: "outbound",
635
+ eventType: "message.reply",
636
+ messageId,
637
+ text: "final answer",
638
+ }));
639
+ expect(store.insertMessage).toHaveBeenCalledTimes(1);
640
+ const thinkingRow = store.insertMessage.mock.calls[0]![0];
641
+ expect(thinkingRow).toMatchObject({
642
+ platform: "openclaw",
643
+ accountId: "default",
644
+ kind: "thinking",
645
+ direction: "outbound",
646
+ eventType: "message.send",
647
+ chatId: "chat-1",
648
+ text: "reasoning text",
649
+ });
650
+ expect(thinkingRow.messageId).toBe(messageId);
651
+ expect(messageId).toEqual(expect.any(String));
652
+ });
653
+
6
654
  it("uses the inbound sender id in consolidated streaming reply previews", async () => {
7
655
  let hooks:
8
656
  | {
@@ -11,18 +659,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
11
659
  }
12
660
  | undefined;
13
661
  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;
662
+ const client = mockClient(sent);
663
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
26
664
 
27
665
  createOpenclawClawlingReplyDispatcher({
28
666
  cfg: {} as never,
@@ -58,6 +696,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
58
696
  senderNickName: "User 1",
59
697
  bodyText: "hello",
60
698
  },
699
+ store: store as never,
61
700
  log: { info: vi.fn(), error: vi.fn() },
62
701
  });
63
702
 
@@ -88,18 +727,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
88
727
  }
89
728
  | undefined;
90
729
  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;
730
+ const client = mockClient(sent);
731
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
103
732
 
104
733
  createOpenclawClawlingReplyDispatcher({
105
734
  cfg: {} as never,
@@ -154,18 +783,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
154
783
  }
155
784
  | undefined;
156
785
  const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
157
- const client = {
158
- opts: {
159
- transport: {
160
- send: (data: string) => {
161
- const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
162
- sent.push({ event: env.event, payload: env.payload });
163
- },
164
- },
165
- traceIdFactory: () => "trace-2",
166
- },
167
- typing: vi.fn(),
168
- } as unknown as ClawlingChatClient;
786
+ const client = mockClient(sent);
787
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
169
788
 
170
789
  createOpenclawClawlingReplyDispatcher({
171
790
  cfg: {} as never,
@@ -200,6 +819,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
200
819
  senderNickName: "User 1",
201
820
  bodyText: "hello",
202
821
  },
822
+ store: store as never,
203
823
  log: { info: vi.fn(), error: vi.fn() },
204
824
  });
205
825
 
@@ -222,13 +842,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
222
842
  onError?: (error: unknown, info: { kind: string }) => void;
223
843
  }
224
844
  | undefined;
225
- const client = {
226
- sendMessage: vi.fn().mockResolvedValue({
227
- payload: { message_id: "server-m1", accepted_at: 1234 },
228
- }),
229
- replyMessage: vi.fn(),
230
- typing: vi.fn(),
231
- } as unknown as ClawlingChatClient;
845
+ const client = mockClient();
232
846
 
233
847
  const logError = vi.fn();
234
848
  createOpenclawClawlingReplyDispatcher({
@@ -264,8 +878,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
264
878
  hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
265
879
  await new Promise((resolve) => setTimeout(resolve, 0));
266
880
 
267
- expect(client.sendMessage).not.toHaveBeenCalled();
268
- expect(client.replyMessage).not.toHaveBeenCalled();
881
+ expect(client.sent).toHaveLength(0);
269
882
  expect(logError).toHaveBeenCalledWith(
270
883
  expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
271
884
  );
@@ -278,13 +891,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
278
891
  }
279
892
  | undefined;
280
893
  const logError = vi.fn();
281
- const client = {
282
- sendMessage: vi.fn().mockRejectedValue(
283
- new Error("ack timeout after 15000ms for trace_id=trace-fallback"),
284
- ),
285
- replyMessage: vi.fn(),
286
- typing: vi.fn(),
287
- } as unknown as ClawlingChatClient;
894
+ const client = mockClient();
895
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
288
896
 
289
897
  createOpenclawClawlingReplyDispatcher({
290
898
  cfg: {} as never,
@@ -319,7 +927,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
319
927
  hooks?.onError?.(new Error("final delivery failed"), { kind: "final" });
320
928
  await new Promise((resolve) => setTimeout(resolve, 0));
321
929
 
322
- expect(client.sendMessage).not.toHaveBeenCalled();
930
+ expect(client.sent).toHaveLength(0);
323
931
  expect(logError).toHaveBeenCalledWith(
324
932
  expect.stringContaining(
325
933
  "openclaw-clawchat final reply failed: Error: final delivery failed",
@@ -333,13 +941,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
333
941
  onError?: (error: unknown, info: { kind: string }) => void;
334
942
  }
335
943
  | undefined;
336
- const client = {
337
- sendMessage: vi.fn().mockResolvedValue({
338
- payload: { message_id: "server-m1", accepted_at: 1234 },
339
- }),
340
- replyMessage: vi.fn(),
341
- typing: vi.fn(),
342
- } as unknown as ClawlingChatClient;
944
+ const client = mockClient();
343
945
 
344
946
  const logError = vi.fn();
345
947
  createOpenclawClawlingReplyDispatcher({
@@ -378,7 +980,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
378
980
  );
379
981
  await new Promise((resolve) => setTimeout(resolve, 0));
380
982
 
381
- expect(client.sendMessage).not.toHaveBeenCalled();
983
+ expect(client.sent).toHaveLength(0);
382
984
  expect(logError).toHaveBeenCalledWith(
383
985
  expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
384
986
  );
@@ -401,14 +1003,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
401
1003
  }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
402
1004
  }
403
1005
  | undefined;
404
- const client = {
405
- sendMessage: vi.fn().mockResolvedValue({
406
- payload: { message_id: "server-m1", accepted_at: 1234 },
407
- trace_id: "trace-rich",
408
- }),
409
- replyMessage: vi.fn(),
410
- typing: vi.fn(),
411
- } as unknown as ClawlingChatClient;
1006
+ const client = mockClient();
1007
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
412
1008
 
413
1009
  createOpenclawClawlingReplyDispatcher({
414
1010
  cfg: {} as never,
@@ -431,9 +1027,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
431
1027
  forwardToolCalls: false,
432
1028
  richInteractions: true,
433
1029
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1030
+ ack: { timeout: 10000, autoResendOnTimeout: false },
434
1031
  } as never,
435
1032
  client,
436
1033
  target: { chatId: "chat-1", chatType: "direct" },
1034
+ store: store as never,
437
1035
  log: { info: vi.fn(), error: vi.fn() },
438
1036
  });
439
1037
 
@@ -458,8 +1056,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
458
1056
  { kind: "final" },
459
1057
  );
460
1058
 
461
- expect(client.sendMessage).toHaveBeenCalledWith(
462
- expect.objectContaining({
1059
+ expect(client.sent[0]?.payload).toMatchObject({
1060
+ message: {
463
1061
  body: {
464
1062
  fragments: [
465
1063
  {
@@ -474,8 +1072,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
474
1072
  },
475
1073
  ],
476
1074
  },
477
- }),
478
- );
1075
+ },
1076
+ });
479
1077
  });
480
1078
 
481
1079
  it("sends plain fallback text for presentations when rich interactions are disabled", async () => {
@@ -490,14 +1088,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
490
1088
  }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
491
1089
  }
492
1090
  | undefined;
493
- const client = {
494
- sendMessage: vi.fn().mockResolvedValue({
495
- payload: { message_id: "server-m1", accepted_at: 1234 },
496
- trace_id: "trace-fallback",
497
- }),
498
- replyMessage: vi.fn(),
499
- typing: vi.fn(),
500
- } as unknown as ClawlingChatClient;
1091
+ const client = mockClient();
1092
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
501
1093
 
502
1094
  createOpenclawClawlingReplyDispatcher({
503
1095
  cfg: {} as never,
@@ -520,9 +1112,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
520
1112
  forwardToolCalls: false,
521
1113
  richInteractions: false,
522
1114
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1115
+ ack: { timeout: 10000, autoResendOnTimeout: false },
523
1116
  } as never,
524
1117
  client,
525
1118
  target: { chatId: "chat-1", chatType: "direct" },
1119
+ store: store as never,
526
1120
  log: { info: vi.fn(), error: vi.fn() },
527
1121
  });
528
1122
 
@@ -537,13 +1131,13 @@ describe("openclaw-clawchat reply-dispatcher", () => {
537
1131
  { kind: "final" },
538
1132
  );
539
1133
 
540
- expect(client.sendMessage).toHaveBeenCalledWith(
541
- expect.objectContaining({
1134
+ expect(client.sent[0]?.payload).toMatchObject({
1135
+ message: {
542
1136
  body: {
543
1137
  fragments: [{ kind: "text", text: expect.stringContaining("Delete /tmp/example.txt?") }],
544
1138
  },
545
- }),
546
- );
1139
+ },
1140
+ });
547
1141
  });
548
1142
 
549
1143
  it("prefers mediaUrls over legacy mediaUrl so one image is not sent twice", async () => {
@@ -566,19 +1160,19 @@ describe("openclaw-clawchat reply-dispatcher", () => {
566
1160
  JSON.stringify({
567
1161
  code: 0,
568
1162
  msg: "ok",
569
- data: { url: "https://cdn/uploaded.png", size: 12, mime: "image/png" },
1163
+ data: {
1164
+ kind: "image",
1165
+ url: "https://cdn/uploaded.png",
1166
+ name: "uploaded.png",
1167
+ size: 12,
1168
+ mime: "image/png",
1169
+ },
570
1170
  }),
571
1171
  { status: 200, headers: { "content-type": "application/json" } },
572
1172
  ),
573
1173
  );
574
- const client = {
575
- sendMessage: vi.fn().mockResolvedValue({
576
- payload: { message_id: "server-m1", accepted_at: 1234 },
577
- trace_id: "trace-media",
578
- }),
579
- replyMessage: vi.fn(),
580
- typing: vi.fn(),
581
- } as unknown as ClawlingChatClient;
1174
+ const client = mockClient();
1175
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
582
1176
 
583
1177
  try {
584
1178
  createOpenclawClawlingReplyDispatcher({
@@ -604,9 +1198,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
604
1198
  forwardThinking: true,
605
1199
  forwardToolCalls: false,
606
1200
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1201
+ ack: { timeout: 10000, autoResendOnTimeout: false },
607
1202
  } as never,
608
1203
  client,
609
1204
  target: { chatId: "chat-1", chatType: "direct" },
1205
+ store: store as never,
610
1206
  log: { info: vi.fn(), error: vi.fn() },
611
1207
  });
612
1208
 
@@ -622,23 +1218,25 @@ describe("openclaw-clawchat reply-dispatcher", () => {
622
1218
  expect(loadWebMedia).toHaveBeenCalledTimes(1);
623
1219
  expect(loadWebMedia).toHaveBeenCalledWith("https://cdn/image.png", expect.any(Object));
624
1220
  expect(fetchMock).toHaveBeenCalledTimes(1);
625
- expect(client.sendMessage).toHaveBeenCalledWith(
626
- expect.objectContaining({
627
- chat_id: "chat-1",
628
- body: {
629
- fragments: [
630
- { kind: "text", text: "look" },
631
- {
632
- kind: "image",
633
- url: "https://cdn/uploaded.png",
634
- mime: "image/png",
635
- size: 12,
636
- name: "image.png",
637
- },
638
- ],
1221
+ expect(client.sent[0]).toMatchObject({
1222
+ chat_id: "chat-1",
1223
+ payload: {
1224
+ message: {
1225
+ body: {
1226
+ fragments: [
1227
+ { kind: "text", text: "look" },
1228
+ {
1229
+ kind: "image",
1230
+ url: "https://cdn/uploaded.png",
1231
+ mime: "image/png",
1232
+ size: 12,
1233
+ name: "uploaded.png",
1234
+ },
1235
+ ],
1236
+ },
639
1237
  },
640
- }),
641
- );
1238
+ },
1239
+ });
642
1240
  } finally {
643
1241
  fetchMock.mockRestore();
644
1242
  }
@@ -662,18 +1260,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
662
1260
  }
663
1261
  | undefined;
664
1262
  const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
665
- const client = {
666
- opts: {
667
- transport: {
668
- send: (data: string) => {
669
- const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
670
- sent.push({ event: env.event, payload: env.payload });
671
- },
672
- },
673
- traceIdFactory: () => "trace-stream-rich",
674
- },
675
- typing: vi.fn(),
676
- } as unknown as ClawlingChatClient;
1263
+ const client = mockClient(sent);
1264
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
677
1265
 
678
1266
  createOpenclawClawlingReplyDispatcher({
679
1267
  cfg: {} as never,
@@ -706,6 +1294,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
706
1294
  senderNickName: "User 1",
707
1295
  bodyText: "hello",
708
1296
  },
1297
+ store: store as never,
709
1298
  log: { info: vi.fn(), error: vi.fn() },
710
1299
  });
711
1300
 
@@ -756,14 +1345,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
756
1345
  }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
757
1346
  }
758
1347
  | undefined;
759
- const client = {
760
- sendMessage: vi.fn().mockResolvedValue({
761
- payload: { message_id: "server-m1", accepted_at: 1234 },
762
- trace_id: "trace-block-rich",
763
- }),
764
- replyMessage: vi.fn(),
765
- typing: vi.fn(),
766
- } as unknown as ClawlingChatClient;
1348
+ const client = mockClient();
1349
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
767
1350
 
768
1351
  createOpenclawClawlingReplyDispatcher({
769
1352
  cfg: {} as never,
@@ -786,9 +1369,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
786
1369
  forwardToolCalls: false,
787
1370
  richInteractions: true,
788
1371
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
1372
+ ack: { timeout: 10000, autoResendOnTimeout: false },
789
1373
  } as never,
790
1374
  client,
791
1375
  target: { chatId: "chat-1", chatType: "direct" },
1376
+ store: store as never,
792
1377
  log: { info: vi.fn(), error: vi.fn() },
793
1378
  });
794
1379
 
@@ -805,8 +1390,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
805
1390
  { kind: "block" },
806
1391
  );
807
1392
 
808
- expect(client.sendMessage).toHaveBeenCalledWith(
809
- expect.objectContaining({
1393
+ expect(client.sent[0]?.payload).toMatchObject({
1394
+ message: {
810
1395
  body: {
811
1396
  fragments: [
812
1397
  {
@@ -818,7 +1403,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
818
1403
  },
819
1404
  ],
820
1405
  },
821
- }),
822
- );
1406
+ },
1407
+ });
823
1408
  });
824
1409
  });