@clawling/clawchat-plugin-openclaw 2026.5.12-28

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 (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
@@ -0,0 +1,1324 @@
1
+ import type { ClawlingChatClient } from "./ws-client.ts";
2
+ import { EventEmitter } from "node:events";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
8
+ import {
9
+ clearTerminalClawChatSendsForTest,
10
+ markTerminalClawChatSend,
11
+ } from "./terminal-send.ts";
12
+
13
+ type SentFrame = {
14
+ event: string;
15
+ payload: Record<string, unknown>;
16
+ chat_id?: string;
17
+ trace_id?: string;
18
+ };
19
+
20
+ type TestClient = ClawlingChatClient & {
21
+ sent: SentFrame[];
22
+ typing: ReturnType<typeof vi.fn>;
23
+ };
24
+
25
+ function mockClient(
26
+ sent: SentFrame[] = [],
27
+ options: { transportState?: "open" | "closed"; state?: string; autoAck?: boolean } = {},
28
+ ): TestClient & { setTransportState: (state: "open" | "closed") => void; setState: (state: string) => void } {
29
+ let trace = 0;
30
+ let transportState = options.transportState ?? "open";
31
+ let clientState = options.state ?? "connected";
32
+ const autoAck = options.autoAck ?? true;
33
+ const client = Object.assign(new EventEmitter(), {
34
+ sent,
35
+ nextTraceId: vi.fn(() => `trace-${++trace}`),
36
+ sendWire: vi.fn((wire: string) => {
37
+ const env = JSON.parse(wire) as SentFrame & { trace_id?: string };
38
+ sent.push({ event: env.event, payload: env.payload, chat_id: env.chat_id, trace_id: env.trace_id });
39
+ if (autoAck && (env.event === "message.send" || env.event === "message.reply")) {
40
+ const payload = env.payload as { message_id?: string };
41
+ queueMicrotask(() => {
42
+ client.emit("raw", {
43
+ version: "2",
44
+ event: "message.ack",
45
+ trace_id: env.trace_id,
46
+ emitted_at: Date.now(),
47
+ payload: { message_id: payload.message_id ?? "server-m1", accepted_at: 1234 },
48
+ });
49
+ });
50
+ }
51
+ }),
52
+ emitRaw: vi.fn((event: string, payload: Record<string, unknown>, routing?: { chat_id?: string }) => {
53
+ sent.push({ event, payload, chat_id: routing?.chat_id });
54
+ }),
55
+ sendRawEnvelope: vi.fn((env: { event: string; payload: Record<string, unknown>; chat_id?: string; trace_id?: string }) => {
56
+ sent.push({ event: env.event, payload: env.payload, chat_id: env.chat_id, trace_id: env.trace_id });
57
+ }),
58
+ typing: vi.fn(),
59
+ setTransportState: (state: "open" | "closed") => {
60
+ transportState = state;
61
+ },
62
+ setState: (state: string) => {
63
+ clientState = state;
64
+ },
65
+ });
66
+ Object.defineProperty(client, "state", { get: () => clientState });
67
+ Object.defineProperty(client, "transportState", { get: () => transportState });
68
+ return client as unknown as TestClient & {
69
+ setTransportState: (state: "open" | "closed") => void;
70
+ setState: (state: string) => void;
71
+ };
72
+ }
73
+
74
+ function runtimeWithHooks(setHooks: (hooks: Record<string, unknown>) => void) {
75
+ return {
76
+ channel: {
77
+ reply: {
78
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
79
+ createReplyDispatcherWithTyping: vi.fn((options) => {
80
+ setHooks(options as Record<string, unknown>);
81
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
82
+ }),
83
+ },
84
+ },
85
+ } as never;
86
+ }
87
+
88
+ function replyAccount(overrides: Record<string, unknown> = {}) {
89
+ return {
90
+ accountId: "default",
91
+ userId: "agent-1",
92
+ ownerUserId: "owner-1",
93
+ forwardThinking: true,
94
+ forwardToolCalls: false,
95
+ richInteractions: false,
96
+ ack: { timeout: 10000, autoResendOnTimeout: false },
97
+ ...overrides,
98
+ } as never;
99
+ }
100
+
101
+ function createDispatcherForTest(options: {
102
+ account?: Record<string, unknown>;
103
+ target?: { chatId: string; chatType: "direct" | "group" };
104
+ sent?: SentFrame[];
105
+ }) {
106
+ let hooks: Record<string, unknown> = {};
107
+ const sent = options.sent ?? [];
108
+ const client = mockClient(sent);
109
+ const created = createOpenclawClawlingReplyDispatcher({
110
+ cfg: {} as never,
111
+ runtime: runtimeWithHooks((next) => {
112
+ hooks = next;
113
+ }),
114
+ account: replyAccount(options.account),
115
+ client,
116
+ target: options.target ?? { chatId: "group-1", chatType: "group" },
117
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn(), updateMessageByIdentity: vi.fn() } as never,
118
+ log: { info: vi.fn(), error: vi.fn() },
119
+ });
120
+ return { created, hooks, client, sent };
121
+ }
122
+
123
+ type TestStore = {
124
+ claimMessageOnce?: (input: unknown) => true | false | null;
125
+ markMessageAcknowledged?: (input: unknown) => boolean | null;
126
+ updateMessageByIdentity?: (input: unknown) => void;
127
+ insertMessage?: (input: unknown) => unknown;
128
+ };
129
+
130
+ async function runStaticReplyWithStore(store: TestStore, text = "static reply") {
131
+ let hooks: Record<string, unknown> = {};
132
+ const sent: SentFrame[] = [];
133
+ const client = mockClient(sent);
134
+
135
+ createOpenclawClawlingReplyDispatcher({
136
+ cfg: {} as never,
137
+ runtime: runtimeWithHooks((next) => {
138
+ hooks = next;
139
+ }),
140
+ account: replyAccount(),
141
+ client,
142
+ target: { chatId: "chat-1", chatType: "direct" },
143
+ store: store as never,
144
+ log: { info: vi.fn(), error: vi.fn() },
145
+ });
146
+
147
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
148
+ { text },
149
+ { kind: "final" },
150
+ );
151
+
152
+ return {
153
+ queuedFinal: sent.some((entry) => entry.event === "message.send" || entry.event === "message.reply"),
154
+ sent,
155
+ };
156
+ }
157
+
158
+ async function runCompleteReplyWithStore(store: TestStore, text: string) {
159
+ let hooks: Record<string, unknown> = {};
160
+ const sent: SentFrame[] = [];
161
+ const client = mockClient(sent);
162
+ createOpenclawClawlingReplyDispatcher({
163
+ cfg: {} as never,
164
+ runtime: runtimeWithHooks((next) => {
165
+ hooks = next;
166
+ }),
167
+ account: replyAccount({ forwardThinking: true }),
168
+ client,
169
+ target: { chatId: "chat-1", chatType: "direct" },
170
+ inboundMessageId: "inbound-1",
171
+ inboundForFinalReply: {
172
+ chatId: "chat-1",
173
+ senderId: "user-1",
174
+ senderNickName: "User 1",
175
+ bodyText: "hello",
176
+ },
177
+ store: store as never,
178
+ log: { info: vi.fn(), error: vi.fn() },
179
+ });
180
+
181
+ await (hooks.onReplyStart as (() => Promise<void>) | undefined)?.();
182
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
183
+ { text },
184
+ { kind: "final" },
185
+ );
186
+ await (hooks.onIdle as () => Promise<void>)();
187
+
188
+ return {
189
+ sent,
190
+ messageId: sent.find((entry) => entry.event === "message.send")?.payload.message_id,
191
+ };
192
+ }
193
+
194
+ describe("clawchat-plugin-openclaw reply-dispatcher", () => {
195
+ const debugRoots: string[] = [];
196
+
197
+ beforeEach(() => {
198
+ clearTerminalClawChatSendsForTest();
199
+ });
200
+
201
+ afterEach(() => {
202
+ vi.unstubAllEnvs();
203
+ for (const root of debugRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
204
+ });
205
+
206
+ function enableLlmContextDebug(): string {
207
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-llm-context-reply-"));
208
+ debugRoots.push(root);
209
+ vi.stubEnv("CLAWCHAT_LLM_CONTEXT_DEBUG", "1");
210
+ vi.stubEnv("CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR", root);
211
+ return root;
212
+ }
213
+
214
+ function readOnlyDebugSnapshot(root: string): Record<string, unknown> {
215
+ const runsDir = path.join(root, "openclaw", "runs");
216
+ const files = fs.readdirSync(runsDir).filter((name) => name.endsWith(".json"));
217
+ expect(files).toHaveLength(1);
218
+ return JSON.parse(fs.readFileSync(path.join(runsDir, files[0]!), "utf8")) as Record<string, unknown>;
219
+ }
220
+
221
+ it("sends ClawChat typing updates for the OpenClaw reply lifecycle", async () => {
222
+ const { hooks, client } = createDispatcherForTest({
223
+ account: {},
224
+ target: { chatId: "chat-1", chatType: "direct" },
225
+ });
226
+
227
+ await (hooks.onReplyStart as () => Promise<void>)();
228
+ await (hooks.onIdle as () => Promise<void>)();
229
+
230
+ expect(client.typing.mock.calls).toEqual([
231
+ ["chat-1", true],
232
+ ["chat-1", false],
233
+ ]);
234
+ });
235
+
236
+ it("writes output snapshots for suppressed and delivered static replies", async () => {
237
+ const suppressedRoot = enableLlmContextDebug();
238
+ let result = await runStaticReplyWithStore({ claimMessageOnce: vi.fn(() => true) }, "<clawchat:no-reply/>");
239
+
240
+ expect(result.sent).toHaveLength(0);
241
+ expect(readOnlyDebugSnapshot(suppressedRoot)).toMatchObject({
242
+ source: "openclaw",
243
+ visibility: "host_event",
244
+ output: {
245
+ rawModelOutput: "<clawchat:no-reply/>",
246
+ adapterFilteredText: "",
247
+ suppressed: true,
248
+ suppressionReason: "no-reply token",
249
+ },
250
+ });
251
+
252
+ vi.unstubAllEnvs();
253
+ const deliveredRoot = enableLlmContextDebug();
254
+ result = await runStaticReplyWithStore({ claimMessageOnce: vi.fn(() => true) }, "visible reply");
255
+
256
+ expect(result.sent).toHaveLength(1);
257
+ expect(readOnlyDebugSnapshot(deliveredRoot)).toMatchObject({
258
+ source: "openclaw",
259
+ visibility: "host_event",
260
+ output: {
261
+ rawModelOutput: "visible reply",
262
+ adapterFilteredText: "visible reply",
263
+ suppressed: false,
264
+ suppressionReason: null,
265
+ },
266
+ });
267
+ });
268
+
269
+ it("forces group replies to suppress reasoning and tool deliveries even when forwarding is enabled", async () => {
270
+ const { hooks, sent } = createDispatcherForTest({
271
+ account: { forwardThinking: true, forwardToolCalls: true },
272
+ });
273
+
274
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }, info: { kind: string }) => Promise<void>)(
275
+ { text: "private reasoning", isReasoning: true },
276
+ { kind: "block" },
277
+ );
278
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
279
+ { text: "tool result" },
280
+ { kind: "tool" },
281
+ );
282
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
283
+ { text: "visible answer" },
284
+ { kind: "final" },
285
+ );
286
+
287
+ expect(sent).toHaveLength(1);
288
+ expect(sent[0]).toMatchObject({
289
+ chat_id: "group-1",
290
+ payload: {
291
+ message: {
292
+ body: {
293
+ fragments: [{ kind: "text", text: "visible answer" }],
294
+ },
295
+ },
296
+ },
297
+ });
298
+ });
299
+
300
+ it("buffers direct tool deliveries and sends only the final complete message", async () => {
301
+ const { hooks, sent } = createDispatcherForTest({
302
+ account: { forwardToolCalls: true },
303
+ target: { chatId: "chat-1", chatType: "direct" },
304
+ });
305
+
306
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
307
+ { text: "tool result" },
308
+ { kind: "tool" },
309
+ );
310
+ expect(sent).toHaveLength(0);
311
+
312
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
313
+ { text: "final answer" },
314
+ { kind: "final" },
315
+ );
316
+
317
+ expect(sent.map((entry) => entry.event)).toEqual(["message.send"]);
318
+ expect(sent[0]).toMatchObject({
319
+ chat_id: "chat-1",
320
+ payload: {
321
+ message: {
322
+ body: {
323
+ fragments: [{ kind: "text", text: "final answer" }],
324
+ },
325
+ },
326
+ },
327
+ });
328
+ });
329
+
330
+ it("does not expose streaming reply hooks", () => {
331
+ const { created } = createDispatcherForTest({
332
+ account: { forwardThinking: true },
333
+ target: { chatId: "user-1", chatType: "direct" },
334
+ });
335
+
336
+ expect(created.replyOptions.onReasoningStream).toBeUndefined();
337
+ expect(created.replyOptions.onPartialReply).toBeUndefined();
338
+ });
339
+
340
+ it("routes group approval and action cards to owner direct chat instead of the group", async () => {
341
+ const { hooks, sent } = createDispatcherForTest({
342
+ account: { richInteractions: false },
343
+ });
344
+
345
+ await (hooks.deliver as (payload: {
346
+ text?: string;
347
+ presentation?: {
348
+ title?: string;
349
+ tone?: string;
350
+ blocks: Array<
351
+ | { type: "text"; text: string }
352
+ | { type: "buttons"; buttons: Array<{ label: string; value?: string; style?: string }> }
353
+ >;
354
+ };
355
+ }, info: { kind: string }) => Promise<void>)(
356
+ {
357
+ text: "Approve deploy?",
358
+ presentation: {
359
+ title: "Approval required",
360
+ tone: "warning",
361
+ blocks: [
362
+ { type: "text", text: "Deploy to production?" },
363
+ {
364
+ type: "buttons",
365
+ buttons: [
366
+ { label: "Approve", value: "approve", style: "primary" },
367
+ { label: "Deny", value: "deny", style: "danger" },
368
+ ],
369
+ },
370
+ ],
371
+ },
372
+ },
373
+ { kind: "final" },
374
+ );
375
+
376
+ expect(sent).toHaveLength(1);
377
+ expect(sent[0]?.chat_id).toBe("owner-1");
378
+ expect(sent[0]?.payload).toMatchObject({
379
+ message: {
380
+ body: {
381
+ fragments: [
382
+ { kind: "text", text: expect.stringContaining("ClawChat group group-1 requires owner attention.") },
383
+ {
384
+ kind: "approval_request",
385
+ title: "Approval required",
386
+ state: "pending",
387
+ actions: [
388
+ { id: "approve", label: "Approve" },
389
+ { id: "deny", label: "Deny" },
390
+ ],
391
+ },
392
+ ],
393
+ },
394
+ },
395
+ });
396
+ });
397
+
398
+ it("suppresses group runtime failures without sending them to any ClawChat client", async () => {
399
+ const { hooks, sent } = createDispatcherForTest({
400
+ account: {},
401
+ });
402
+
403
+ (hooks.onError as (error: unknown, info: { kind: string }) => void)(
404
+ new Error("secret stack"),
405
+ { kind: "dispatch" },
406
+ );
407
+ await new Promise((resolve) => setTimeout(resolve, 0));
408
+
409
+ expect(sent).toHaveLength(0);
410
+ });
411
+
412
+ it("forces complete block delivery and omits streaming hooks", () => {
413
+ let hooks: Record<string, unknown> = {};
414
+ const sent: SentFrame[] = [];
415
+ const client = mockClient(sent);
416
+
417
+ const created = createOpenclawClawlingReplyDispatcher({
418
+ cfg: {} as never,
419
+ runtime: runtimeWithHooks((next) => {
420
+ hooks = next;
421
+ }),
422
+ account: replyAccount({}),
423
+ client,
424
+ target: { chatId: "chat-1", chatType: "direct" },
425
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
426
+ log: { info: vi.fn(), error: vi.fn() },
427
+ });
428
+
429
+ expect(hooks).toBeTruthy();
430
+ expect((created.replyOptions as Record<string, unknown>).sourceReplyDeliveryMode).toBe("automatic");
431
+ expect((created.replyOptions as Record<string, unknown>).disableBlockStreaming).toBe(true);
432
+ expect(created.replyOptions.onPartialReply).toBeUndefined();
433
+ expect(created.replyOptions.onReasoningStream).toBeUndefined();
434
+ });
435
+
436
+ it("suppresses one static final reply after a terminal ClawChat tool send", async () => {
437
+ let hooks: Record<string, unknown> = {};
438
+ const sent: SentFrame[] = [];
439
+ const client = mockClient(sent);
440
+ const log = { info: vi.fn(), error: vi.fn() };
441
+
442
+ createOpenclawClawlingReplyDispatcher({
443
+ cfg: {} as never,
444
+ runtime: runtimeWithHooks((next) => {
445
+ hooks = next;
446
+ }),
447
+ account: replyAccount(),
448
+ client,
449
+ target: { chatId: "chat-1", chatType: "direct" },
450
+ terminalSendScopeId: "scope-1",
451
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
452
+ log,
453
+ });
454
+
455
+ markTerminalClawChatSend({
456
+ accountId: "default",
457
+ chatId: "chat-1",
458
+ messageId: "msg-terminal",
459
+ scopeId: "scope-1",
460
+ });
461
+
462
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: "final" }) => Promise<void>)(
463
+ { text: "already sent" },
464
+ { kind: "final" },
465
+ );
466
+
467
+ expect(sent).toHaveLength(0);
468
+ expect(log.info).toHaveBeenCalledWith(
469
+ expect.stringContaining("suppressing final reply after terminal tool send msg=msg-terminal"),
470
+ );
471
+
472
+ let nextHooks: Record<string, unknown> = {};
473
+ createOpenclawClawlingReplyDispatcher({
474
+ cfg: {} as never,
475
+ runtime: runtimeWithHooks((next) => {
476
+ nextHooks = next;
477
+ }),
478
+ account: replyAccount(),
479
+ client,
480
+ target: { chatId: "chat-1", chatType: "direct" },
481
+ terminalSendScopeId: "scope-2",
482
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
483
+ log,
484
+ });
485
+
486
+ await (nextHooks.deliver as (payload: { text?: string }, info: { kind: "final" }) => Promise<void>)(
487
+ { text: "next turn" },
488
+ { kind: "final" },
489
+ );
490
+
491
+ expect(sent.some((entry) => entry.event === "message.send")).toBe(true);
492
+ });
493
+
494
+ it("suppresses complete final reply after a terminal ClawChat tool send", async () => {
495
+ let hooks: Record<string, unknown> = {};
496
+ const sent: SentFrame[] = [];
497
+ const client = mockClient(sent);
498
+ const created = createOpenclawClawlingReplyDispatcher({
499
+ cfg: {} as never,
500
+ runtime: runtimeWithHooks((next) => {
501
+ hooks = next;
502
+ }),
503
+ account: replyAccount({}),
504
+ client,
505
+ target: { chatId: "chat-1", chatType: "direct" },
506
+ inboundMessageId: "inbound-1",
507
+ terminalSendScopeId: "scope-1",
508
+ inboundForFinalReply: {
509
+ chatId: "chat-1",
510
+ senderId: "user-1",
511
+ senderNickName: "User 1",
512
+ bodyText: "hello",
513
+ },
514
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
515
+ log: { info: vi.fn(), error: vi.fn() },
516
+ });
517
+
518
+ expect(created.replyOptions.onPartialReply).toBeUndefined();
519
+ markTerminalClawChatSend({
520
+ accountId: "default",
521
+ chatId: "chat-1",
522
+ messageId: "msg-terminal",
523
+ scopeId: "scope-1",
524
+ });
525
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: "final" }) => Promise<void>)(
526
+ { text: "partial final" },
527
+ { kind: "final" },
528
+ );
529
+ await (hooks.onIdle as () => Promise<void>)();
530
+
531
+ expect(sent.some((entry) => entry.event === "message.reply")).toBe(false);
532
+ expect(sent.some((entry) => entry.event === "message.send")).toBe(false);
533
+ });
534
+
535
+ it("buffers OpenClaw block deliveries and sends only the final complete message", async () => {
536
+ let hooks: Record<string, unknown> = {};
537
+ const sent: SentFrame[] = [];
538
+ const client = mockClient(sent);
539
+ const store = {
540
+ claimMessageOnce: vi.fn(() => true),
541
+ updateMessageByIdentity: vi.fn(),
542
+ insertMessage: vi.fn(),
543
+ };
544
+
545
+ const created = createOpenclawClawlingReplyDispatcher({
546
+ cfg: {} as never,
547
+ runtime: runtimeWithHooks((next) => {
548
+ hooks = next;
549
+ }),
550
+ account: replyAccount({}),
551
+ client,
552
+ target: { chatId: "chat-1", chatType: "direct" },
553
+ inboundMessageId: "inbound-1",
554
+ inboundForFinalReply: {
555
+ chatId: "chat-1",
556
+ senderId: "user-1",
557
+ senderNickName: "User 1",
558
+ bodyText: "hello",
559
+ },
560
+ store: store as never,
561
+ log: { info: vi.fn(), error: vi.fn() },
562
+ });
563
+
564
+ expect(created.replyOptions.onPartialReply).toBeUndefined();
565
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: "block" }) => Promise<void>)(
566
+ { text: "partial answer" },
567
+ { kind: "block" },
568
+ );
569
+ expect(sent).toHaveLength(0);
570
+ expect(store.claimMessageOnce).not.toHaveBeenCalled();
571
+
572
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: "final" }) => Promise<void>)(
573
+ { text: "final answer" },
574
+ { kind: "final" },
575
+ );
576
+ await (hooks.onIdle as () => Promise<void>)();
577
+
578
+ expect(sent.map((entry) => entry.event)).toEqual(["message.send"]);
579
+ expect(sent[0]?.payload).toMatchObject({
580
+ message: {
581
+ body: { fragments: [{ kind: "text", text: "final answer" }] },
582
+ },
583
+ });
584
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
585
+ eventType: "message.send",
586
+ text: "final answer",
587
+ }));
588
+ });
589
+
590
+ it("forces complete block delivery for explicit static mode", () => {
591
+ const client = mockClient();
592
+
593
+ const created = createOpenclawClawlingReplyDispatcher({
594
+ cfg: {} as never,
595
+ runtime: runtimeWithHooks(() => {}),
596
+ account: replyAccount({}),
597
+ client,
598
+ target: { chatId: "chat-1", chatType: "direct" },
599
+ store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
600
+ log: { info: vi.fn(), error: vi.fn() },
601
+ });
602
+
603
+ expect((created.replyOptions as Record<string, unknown>).disableBlockStreaming).toBe(true);
604
+ expect(created.replyOptions.onPartialReply).toBeUndefined();
605
+ expect(created.replyOptions.onReasoningStream).toBeUndefined();
606
+ });
607
+
608
+ it("claims static outbound before send and uses the claimed payload message_id", async () => {
609
+ const store = {
610
+ claimMessageOnce: vi.fn(() => true),
611
+ markMessageAcknowledged: vi.fn(() => true),
612
+ insertMessage: vi.fn(),
613
+ };
614
+
615
+ const result = await runStaticReplyWithStore(store);
616
+ const messageId = store.claimMessageOnce.mock.calls[0]![0].messageId;
617
+
618
+ expect(messageId).toEqual(expect.any(String));
619
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
620
+ direction: "outbound",
621
+ kind: "message",
622
+ messageId,
623
+ }));
624
+ expect(result.sent[0]?.payload.message_id).toBe(messageId);
625
+ expect(store.markMessageAcknowledged).toHaveBeenCalledWith({
626
+ accountId: "default",
627
+ kind: "message",
628
+ direction: "outbound",
629
+ messageId,
630
+ protocolMessageId: messageId,
631
+ ackedAt: 1234,
632
+ });
633
+ expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
634
+ });
635
+
636
+ it("does not send static outbound when the storage claim is duplicate or unavailable", async () => {
637
+ const duplicateStore = { claimMessageOnce: vi.fn(() => false), insertMessage: vi.fn() };
638
+ const unavailableStore = { claimMessageOnce: vi.fn(() => null), insertMessage: vi.fn() };
639
+
640
+ await expect(runStaticReplyWithStore(duplicateStore)).resolves.toMatchObject({ queuedFinal: false });
641
+ await expect(runStaticReplyWithStore(unavailableStore)).resolves.toMatchObject({ queuedFinal: false });
642
+ expect(duplicateStore.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
643
+ direction: "outbound",
644
+ kind: "message",
645
+ }));
646
+ });
647
+
648
+ it("buffers forwarded thinking and records it only with the final complete message", async () => {
649
+ let hooks: Record<string, unknown> = {};
650
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
651
+ const sent: SentFrame[] = [];
652
+ const client = mockClient(sent);
653
+
654
+ createOpenclawClawlingReplyDispatcher({
655
+ cfg: {} as never,
656
+ runtime: runtimeWithHooks((next) => {
657
+ hooks = next;
658
+ }),
659
+ account: replyAccount({ forwardThinking: true }),
660
+ client,
661
+ target: { chatId: "chat-1", chatType: "direct" },
662
+ store: store as never,
663
+ log: { info: vi.fn(), error: vi.fn() },
664
+ });
665
+
666
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
667
+ { text: "thinking text", isReasoning: true },
668
+ );
669
+ expect(sent).toHaveLength(0);
670
+ expect(store.claimMessageOnce).not.toHaveBeenCalled();
671
+
672
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: "final" }) => Promise<void>)(
673
+ { text: "final answer" },
674
+ { kind: "final" },
675
+ );
676
+
677
+ const messageId = store.claimMessageOnce.mock.calls[0]?.[0].messageId;
678
+
679
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
680
+ kind: "message",
681
+ direction: "outbound",
682
+ eventType: "message.send",
683
+ messageId,
684
+ text: "final answer",
685
+ }));
686
+ expect(sent[0]?.payload.message_id).toBe(messageId);
687
+ expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
688
+ kind: "thinking",
689
+ messageId,
690
+ text: "thinking text",
691
+ }));
692
+ });
693
+
694
+ it("does not claim or send reasoning-only deliveries", async () => {
695
+ for (const claimResult of [false, null] as const) {
696
+ let hooks: Record<string, unknown> = {};
697
+ const store = { claimMessageOnce: vi.fn(() => claimResult), insertMessage: vi.fn() };
698
+ const sent: SentFrame[] = [];
699
+ const client = mockClient(sent);
700
+
701
+ createOpenclawClawlingReplyDispatcher({
702
+ cfg: {} as never,
703
+ runtime: runtimeWithHooks((next) => {
704
+ hooks = next;
705
+ }),
706
+ account: replyAccount({ forwardThinking: true }),
707
+ client,
708
+ target: { chatId: "chat-1", chatType: "direct" },
709
+ store: store as never,
710
+ log: { info: vi.fn(), error: vi.fn() },
711
+ });
712
+
713
+ await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
714
+ { text: "thinking text", isReasoning: true },
715
+ );
716
+
717
+ expect(store.claimMessageOnce).not.toHaveBeenCalled();
718
+ expect(sent).toHaveLength(0);
719
+ expect(store.insertMessage).not.toHaveBeenCalled();
720
+ }
721
+ });
722
+
723
+ it("claims outbound final messages before sending", async () => {
724
+ const store = {
725
+ claimMessageOnce: vi.fn(() => true),
726
+ updateMessageByIdentity: vi.fn(),
727
+ insertMessage: vi.fn(),
728
+ };
729
+
730
+ const result = await runCompleteReplyWithStore(store, "hello final");
731
+
732
+ expect(result.messageId).toEqual(expect.any(String));
733
+ expect(result.sent.map((entry) => entry.event)).toEqual(["message.send"]);
734
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
735
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
736
+ direction: "outbound",
737
+ eventType: "message.send",
738
+ messageId: result.messageId,
739
+ text: "hello final",
740
+ }));
741
+ });
742
+
743
+ it("does not append a static message row after the pre-send claim", async () => {
744
+ let hooks: Record<string, unknown> = {};
745
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
746
+ const client = mockClient();
747
+
748
+ createOpenclawClawlingReplyDispatcher({
749
+ cfg: {} as never,
750
+ runtime: runtimeWithHooks((next) => {
751
+ hooks = next;
752
+ }),
753
+ account: replyAccount(),
754
+ client,
755
+ target: { chatId: "chat-1", chatType: "direct" },
756
+ store,
757
+ log: { info: vi.fn(), error: vi.fn() },
758
+ });
759
+
760
+ await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
761
+ { text: "static reply" },
762
+ { kind: "final" },
763
+ );
764
+
765
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
766
+ kind: "message",
767
+ direction: "outbound",
768
+ eventType: "message.send",
769
+ text: "static reply",
770
+ }));
771
+ expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
772
+ });
773
+
774
+ it("does not emit lifecycle failure frames", async () => {
775
+ let hooks:
776
+ | {
777
+ deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
778
+ onIdle?: () => Promise<void>;
779
+ onError?: (error: unknown, info: { kind: string }) => void;
780
+ }
781
+ | undefined;
782
+ const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
783
+ const client = mockClient(sent);
784
+ const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
785
+
786
+ createOpenclawClawlingReplyDispatcher({
787
+ cfg: {} as never,
788
+ runtime: {
789
+ channel: {
790
+ reply: {
791
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
792
+ createReplyDispatcherWithTyping: vi.fn((options) => {
793
+ hooks = options;
794
+ return {
795
+ dispatcher: {},
796
+ replyOptions: {},
797
+ markDispatchIdle: vi.fn(),
798
+ };
799
+ }),
800
+ },
801
+ },
802
+ } as never,
803
+ account: {
804
+ accountId: "default",
805
+ userId: "agent-1",
806
+ forwardThinking: true,
807
+ forwardToolCalls: false,
808
+ } as never,
809
+ client,
810
+ target: { chatId: "chat-1", chatType: "direct" },
811
+ inboundMessageId: "inbound-1",
812
+ inboundForFinalReply: {
813
+ senderId: "user-1",
814
+ senderNickName: "User 1",
815
+ bodyText: "hello",
816
+ },
817
+ store: store as never,
818
+ log: { info: vi.fn(), error: vi.fn() },
819
+ });
820
+
821
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
822
+ await hooks?.onIdle?.();
823
+
824
+ expect(sent).toHaveLength(0);
825
+ expect(sent.find((entry) => entry.event === "message.reply")).toBeUndefined();
826
+ });
827
+
828
+ it("does not send static error text when non-streaming reply execution fails", async () => {
829
+ let hooks:
830
+ | {
831
+ onError?: (error: unknown, info: { kind: string }) => void;
832
+ }
833
+ | undefined;
834
+ const client = mockClient();
835
+
836
+ const logError = vi.fn();
837
+ createOpenclawClawlingReplyDispatcher({
838
+ cfg: {} as never,
839
+ runtime: {
840
+ channel: {
841
+ reply: {
842
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
843
+ createReplyDispatcherWithTyping: vi.fn((options) => {
844
+ hooks = options;
845
+ return {
846
+ dispatcher: {},
847
+ replyOptions: {},
848
+ markDispatchIdle: vi.fn(),
849
+ };
850
+ }),
851
+ },
852
+ },
853
+ } as never,
854
+ account: {
855
+ accountId: "default",
856
+ userId: "agent-1",
857
+ forwardThinking: true,
858
+ forwardToolCalls: false,
859
+ } as never,
860
+ client,
861
+ target: { chatId: "chat-1", chatType: "direct" },
862
+ log: { info: vi.fn(), error: logError },
863
+ });
864
+
865
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
866
+ await new Promise((resolve) => setTimeout(resolve, 0));
867
+
868
+ expect(client.sent).toHaveLength(0);
869
+ expect(logError).toHaveBeenCalledWith(
870
+ expect.stringContaining("clawchat-plugin-openclaw dispatch reply failed: Error: boom"),
871
+ );
872
+ });
873
+
874
+ it("does not attempt fallback static sends after non-streaming reply failures", async () => {
875
+ let hooks:
876
+ | {
877
+ onError?: (error: unknown, info: { kind: string }) => void;
878
+ }
879
+ | undefined;
880
+ const logError = vi.fn();
881
+ const client = mockClient();
882
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
883
+
884
+ createOpenclawClawlingReplyDispatcher({
885
+ cfg: {} as never,
886
+ runtime: {
887
+ channel: {
888
+ reply: {
889
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
890
+ createReplyDispatcherWithTyping: vi.fn((options) => {
891
+ hooks = options;
892
+ return {
893
+ dispatcher: {},
894
+ replyOptions: {},
895
+ markDispatchIdle: vi.fn(),
896
+ };
897
+ }),
898
+ },
899
+ },
900
+ } as never,
901
+ account: {
902
+ accountId: "default",
903
+ userId: "agent-1",
904
+ forwardThinking: true,
905
+ forwardToolCalls: false,
906
+ } as never,
907
+ client,
908
+ target: { chatId: "chat-1", chatType: "direct" },
909
+ log: { info: vi.fn(), error: logError },
910
+ });
911
+
912
+ hooks?.onError?.(new Error("final delivery failed"), { kind: "final" });
913
+ await new Promise((resolve) => setTimeout(resolve, 0));
914
+
915
+ expect(client.sent).toHaveLength(0);
916
+ expect(logError).toHaveBeenCalledWith(
917
+ expect.stringContaining(
918
+ "clawchat-plugin-openclaw final reply failed: Error: final delivery failed",
919
+ ),
920
+ );
921
+ });
922
+
923
+ it("strips delivery retry wrapper text before logging non-streaming errors", async () => {
924
+ let hooks:
925
+ | {
926
+ onError?: (error: unknown, info: { kind: string }) => void;
927
+ }
928
+ | undefined;
929
+ const client = mockClient();
930
+
931
+ const logError = vi.fn();
932
+ createOpenclawClawlingReplyDispatcher({
933
+ cfg: {} as never,
934
+ runtime: {
935
+ channel: {
936
+ reply: {
937
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
938
+ createReplyDispatcherWithTyping: vi.fn((options) => {
939
+ hooks = options;
940
+ return {
941
+ dispatcher: {},
942
+ replyOptions: {},
943
+ markDispatchIdle: vi.fn(),
944
+ };
945
+ }),
946
+ },
947
+ },
948
+ } as never,
949
+ account: {
950
+ accountId: "default",
951
+ userId: "agent-1",
952
+ forwardThinking: true,
953
+ forwardToolCalls: false,
954
+ } as never,
955
+ client,
956
+ target: { chatId: "chat-1", chatType: "direct" },
957
+ log: { info: vi.fn(), error: logError },
958
+ });
959
+
960
+ hooks?.onError?.(
961
+ new Error("Retry failed for delivery 123: Error: boom"),
962
+ { kind: "dispatch" },
963
+ );
964
+ await new Promise((resolve) => setTimeout(resolve, 0));
965
+
966
+ expect(client.sent).toHaveLength(0);
967
+ expect(logError).toHaveBeenCalledWith(
968
+ expect.stringContaining("clawchat-plugin-openclaw dispatch reply failed: Error: boom"),
969
+ );
970
+ expect(logError).not.toHaveBeenCalledWith(expect.stringContaining("Retry failed"));
971
+ });
972
+
973
+ it("emits approval rich fragments with fallback_text when rich interactions are enabled", async () => {
974
+ let hooks:
975
+ | {
976
+ deliver?: (payload: {
977
+ text?: string;
978
+ presentation?: {
979
+ title?: string;
980
+ tone?: string;
981
+ blocks: Array<
982
+ | { type: "text"; text: string }
983
+ | { type: "buttons"; buttons: Array<{ label: string; value?: string; style?: string }> }
984
+ >;
985
+ };
986
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
987
+ }
988
+ | undefined;
989
+ const client = mockClient();
990
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
991
+
992
+ createOpenclawClawlingReplyDispatcher({
993
+ cfg: {} as never,
994
+ runtime: {
995
+ channel: {
996
+ reply: {
997
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
998
+ createReplyDispatcherWithTyping: vi.fn((options) => {
999
+ hooks = options;
1000
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
1001
+ }),
1002
+ },
1003
+ },
1004
+ } as never,
1005
+ account: {
1006
+ accountId: "default",
1007
+ userId: "agent-1",
1008
+ forwardThinking: true,
1009
+ forwardToolCalls: false,
1010
+ richInteractions: true,
1011
+ ack: { timeout: 10000, autoResendOnTimeout: false },
1012
+ } as never,
1013
+ client,
1014
+ target: { chatId: "chat-1", chatType: "direct" },
1015
+ store: store as never,
1016
+ log: { info: vi.fn(), error: vi.fn() },
1017
+ });
1018
+
1019
+ await hooks?.deliver?.(
1020
+ {
1021
+ text: "Approve file deletion?",
1022
+ presentation: {
1023
+ title: "Approval required",
1024
+ tone: "warning",
1025
+ blocks: [
1026
+ { type: "text", text: "Delete /tmp/example.txt?" },
1027
+ {
1028
+ type: "buttons",
1029
+ buttons: [
1030
+ { label: "Approve", value: "approve", style: "primary" },
1031
+ { label: "Deny", value: "deny", style: "danger" },
1032
+ ],
1033
+ },
1034
+ ],
1035
+ },
1036
+ },
1037
+ { kind: "final" },
1038
+ );
1039
+
1040
+ expect(client.sent[0]?.payload).toMatchObject({
1041
+ message: {
1042
+ body: {
1043
+ fragments: [
1044
+ {
1045
+ kind: "approval_request",
1046
+ title: "Approval required",
1047
+ fallback_text: expect.stringContaining("Delete /tmp/example.txt?"),
1048
+ state: "pending",
1049
+ actions: [
1050
+ { id: "approve", label: "Approve", style: "primary", payload: { value: "approve" } },
1051
+ { id: "deny", label: "Deny", style: "danger", payload: { value: "deny" } },
1052
+ ],
1053
+ },
1054
+ ],
1055
+ },
1056
+ },
1057
+ });
1058
+ });
1059
+
1060
+ it("sends plain fallback text for presentations when rich interactions are disabled", async () => {
1061
+ let hooks:
1062
+ | {
1063
+ deliver?: (payload: {
1064
+ text?: string;
1065
+ presentation?: {
1066
+ title?: string;
1067
+ blocks: Array<{ type: "text"; text: string }>;
1068
+ };
1069
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
1070
+ }
1071
+ | undefined;
1072
+ const client = mockClient();
1073
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
1074
+
1075
+ createOpenclawClawlingReplyDispatcher({
1076
+ cfg: {} as never,
1077
+ runtime: {
1078
+ channel: {
1079
+ reply: {
1080
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1081
+ createReplyDispatcherWithTyping: vi.fn((options) => {
1082
+ hooks = options;
1083
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
1084
+ }),
1085
+ },
1086
+ },
1087
+ } as never,
1088
+ account: {
1089
+ accountId: "default",
1090
+ userId: "agent-1",
1091
+ forwardThinking: true,
1092
+ forwardToolCalls: false,
1093
+ richInteractions: false,
1094
+ ack: { timeout: 10000, autoResendOnTimeout: false },
1095
+ } as never,
1096
+ client,
1097
+ target: { chatId: "chat-1", chatType: "direct" },
1098
+ store: store as never,
1099
+ log: { info: vi.fn(), error: vi.fn() },
1100
+ });
1101
+
1102
+ await hooks?.deliver?.(
1103
+ {
1104
+ text: "Approve file deletion?",
1105
+ presentation: {
1106
+ title: "Approval required",
1107
+ blocks: [{ type: "text", text: "Delete /tmp/example.txt?" }],
1108
+ },
1109
+ },
1110
+ { kind: "final" },
1111
+ );
1112
+
1113
+ expect(client.sent[0]?.payload).toMatchObject({
1114
+ message: {
1115
+ body: {
1116
+ fragments: [{ kind: "text", text: expect.stringContaining("Delete /tmp/example.txt?") }],
1117
+ },
1118
+ },
1119
+ });
1120
+ });
1121
+
1122
+ it("prefers mediaUrls over legacy mediaUrl so one image is not sent twice", async () => {
1123
+ let hooks:
1124
+ | {
1125
+ deliver?: (payload: {
1126
+ text?: string;
1127
+ mediaUrl?: string;
1128
+ mediaUrls?: string[];
1129
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
1130
+ }
1131
+ | undefined;
1132
+ const loadWebMedia = vi.fn(async (url: string) => ({
1133
+ buffer: Buffer.from(`bytes:${url}`),
1134
+ contentType: "image/png",
1135
+ fileName: "image.png",
1136
+ }));
1137
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1138
+ new Response(
1139
+ JSON.stringify({
1140
+ code: 0,
1141
+ msg: "ok",
1142
+ data: {
1143
+ kind: "image",
1144
+ url: "https://cdn/uploaded.png",
1145
+ name: "uploaded.png",
1146
+ size: 12,
1147
+ mime: "image/png",
1148
+ },
1149
+ }),
1150
+ { status: 200, headers: { "content-type": "application/json" } },
1151
+ ),
1152
+ );
1153
+ const client = mockClient();
1154
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
1155
+
1156
+ try {
1157
+ createOpenclawClawlingReplyDispatcher({
1158
+ cfg: {} as never,
1159
+ runtime: {
1160
+ media: { loadWebMedia },
1161
+ channel: {
1162
+ reply: {
1163
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1164
+ createReplyDispatcherWithTyping: vi.fn((options) => {
1165
+ hooks = options;
1166
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
1167
+ }),
1168
+ },
1169
+ },
1170
+ } as never,
1171
+ account: {
1172
+ accountId: "default",
1173
+ baseUrl: "https://api.example.com",
1174
+ token: "tk",
1175
+ userId: "agent-1",
1176
+ forwardThinking: true,
1177
+ forwardToolCalls: false,
1178
+ ack: { timeout: 10000, autoResendOnTimeout: false },
1179
+ } as never,
1180
+ client,
1181
+ target: { chatId: "chat-1", chatType: "direct" },
1182
+ store: store as never,
1183
+ log: { info: vi.fn(), error: vi.fn() },
1184
+ });
1185
+
1186
+ await hooks?.deliver?.(
1187
+ {
1188
+ text: "draft",
1189
+ mediaUrls: ["https://cdn/draft.png"],
1190
+ },
1191
+ { kind: "block" },
1192
+ );
1193
+
1194
+ expect(loadWebMedia).not.toHaveBeenCalled();
1195
+ expect(fetchMock).not.toHaveBeenCalled();
1196
+ expect(client.sent).toHaveLength(0);
1197
+
1198
+ await hooks?.deliver?.(
1199
+ {
1200
+ text: "look",
1201
+ mediaUrl: "https://cdn/legacy.png",
1202
+ mediaUrls: ["https://cdn/image.png"],
1203
+ },
1204
+ { kind: "final" },
1205
+ );
1206
+
1207
+ expect(loadWebMedia).toHaveBeenCalledTimes(1);
1208
+ expect(loadWebMedia).toHaveBeenCalledWith("https://cdn/image.png", expect.any(Object));
1209
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1210
+ expect(client.sent[0]).toMatchObject({
1211
+ chat_id: "chat-1",
1212
+ payload: {
1213
+ message: {
1214
+ body: {
1215
+ fragments: [
1216
+ { kind: "text", text: "look" },
1217
+ {
1218
+ kind: "image",
1219
+ url: "https://cdn/uploaded.png",
1220
+ mime: "image/png",
1221
+ size: 12,
1222
+ name: "uploaded.png",
1223
+ },
1224
+ ],
1225
+ },
1226
+ },
1227
+ },
1228
+ });
1229
+ } finally {
1230
+ fetchMock.mockRestore();
1231
+ }
1232
+ });
1233
+
1234
+ it("buffers non-final rich block payloads in static mode", async () => {
1235
+ let hooks:
1236
+ | {
1237
+ deliver?: (payload: {
1238
+ text?: string;
1239
+ presentation?: {
1240
+ title?: string;
1241
+ blocks: Array<
1242
+ | { type: "text"; text: string }
1243
+ | { type: "buttons"; buttons: Array<{ label: string; value?: string }> }
1244
+ >;
1245
+ };
1246
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
1247
+ }
1248
+ | undefined;
1249
+ const client = mockClient();
1250
+ const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
1251
+
1252
+ createOpenclawClawlingReplyDispatcher({
1253
+ cfg: {} as never,
1254
+ runtime: {
1255
+ channel: {
1256
+ reply: {
1257
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
1258
+ createReplyDispatcherWithTyping: vi.fn((options) => {
1259
+ hooks = options;
1260
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
1261
+ }),
1262
+ },
1263
+ },
1264
+ } as never,
1265
+ account: {
1266
+ accountId: "default",
1267
+ userId: "agent-1",
1268
+ forwardThinking: true,
1269
+ forwardToolCalls: false,
1270
+ richInteractions: true,
1271
+ ack: { timeout: 10000, autoResendOnTimeout: false },
1272
+ } as never,
1273
+ client,
1274
+ target: { chatId: "chat-1", chatType: "direct" },
1275
+ store: store as never,
1276
+ log: { info: vi.fn(), error: vi.fn() },
1277
+ });
1278
+
1279
+ await hooks?.deliver?.(
1280
+ {
1281
+ presentation: {
1282
+ title: "Choose next step",
1283
+ blocks: [
1284
+ { type: "text", text: "Pick an action" },
1285
+ { type: "buttons", buttons: [{ label: "Continue", value: "continue" }] },
1286
+ ],
1287
+ },
1288
+ },
1289
+ { kind: "block" },
1290
+ );
1291
+
1292
+ expect(client.sent).toHaveLength(0);
1293
+ expect(store.claimMessageOnce).not.toHaveBeenCalled();
1294
+
1295
+ await hooks?.deliver?.(
1296
+ {
1297
+ presentation: {
1298
+ title: "Choose next step",
1299
+ blocks: [
1300
+ { type: "text", text: "Pick an action" },
1301
+ { type: "buttons", buttons: [{ label: "Continue", value: "continue" }] },
1302
+ ],
1303
+ },
1304
+ },
1305
+ { kind: "final" },
1306
+ );
1307
+
1308
+ expect(client.sent[0]?.payload).toMatchObject({
1309
+ message: {
1310
+ body: {
1311
+ fragments: [
1312
+ {
1313
+ kind: "action_card",
1314
+ title: "Choose next step",
1315
+ fallback_text: expect.stringContaining("Pick an action"),
1316
+ state: "pending",
1317
+ actions: [{ id: "continue", label: "Continue", payload: { value: "continue" } }],
1318
+ },
1319
+ ],
1320
+ },
1321
+ },
1322
+ });
1323
+ });
1324
+ });