@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.4.30

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 (47) hide show
  1. package/README.md +33 -10
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +191 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +214 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +130 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/openclaw.plugin.json +12 -0
  22. package/package.json +25 -5
  23. package/skills/clawchat-activate/SKILL.md +17 -8
  24. package/src/buffered-stream.test.ts +10 -0
  25. package/src/buffered-stream.ts +6 -6
  26. package/src/channel.outbound.test.ts +3 -3
  27. package/src/channel.ts +11 -3
  28. package/src/client.test.ts +8 -1
  29. package/src/client.ts +11 -10
  30. package/src/commands.test.ts +6 -0
  31. package/src/commands.ts +5 -1
  32. package/src/config.test.ts +3 -0
  33. package/src/config.ts +7 -0
  34. package/src/inbound.test.ts +4 -1
  35. package/src/inbound.ts +11 -10
  36. package/src/login.runtime.test.ts +36 -0
  37. package/src/login.runtime.ts +54 -26
  38. package/src/manifest.test.ts +98 -22
  39. package/src/outbound.test.ts +6 -5
  40. package/src/outbound.ts +8 -7
  41. package/src/reply-dispatcher.test.ts +418 -3
  42. package/src/reply-dispatcher.ts +137 -12
  43. package/src/runtime.ts +1 -0
  44. package/src/streaming.test.ts +12 -9
  45. package/src/streaming.ts +6 -6
  46. package/src/tools.test.ts +81 -18
  47. package/src/tools.ts +63 -72
@@ -3,6 +3,82 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
4
4
 
5
5
  describe("openclaw-clawchat reply-dispatcher", () => {
6
+ it("uses chat_id, not sender_id, as the consolidated streaming reply marker", async () => {
7
+ let hooks:
8
+ | {
9
+ deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
10
+ onIdle?: () => Promise<void>;
11
+ }
12
+ | undefined;
13
+ 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;
26
+
27
+ createOpenclawClawlingReplyDispatcher({
28
+ cfg: {} as never,
29
+ runtime: {
30
+ channel: {
31
+ reply: {
32
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
33
+ createReplyDispatcherWithTyping: vi.fn((options) => {
34
+ hooks = options;
35
+ return {
36
+ dispatcher: {},
37
+ replyOptions: {},
38
+ markDispatchIdle: vi.fn(),
39
+ };
40
+ }),
41
+ },
42
+ },
43
+ } as never,
44
+ account: {
45
+ accountId: "default",
46
+ userId: "agent-1",
47
+ replyMode: "stream",
48
+ forwardThinking: true,
49
+ forwardToolCalls: false,
50
+ stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
51
+ } as never,
52
+ client,
53
+ target: { chatId: "chat-1", chatType: "direct" },
54
+ inboundMessageId: "inbound-1",
55
+ inboundForFinalReply: {
56
+ chatId: "chat-1",
57
+ senderId: "user-1",
58
+ senderNickName: "User 1",
59
+ bodyText: "hello",
60
+ },
61
+ log: { info: vi.fn(), error: vi.fn() },
62
+ });
63
+
64
+ await hooks?.deliver?.({ text: "agent reply" }, { kind: "final" });
65
+ await hooks?.onIdle?.();
66
+
67
+ const finalReply = sent.find((entry) => entry.event === "message.reply");
68
+ expect(finalReply?.payload).toMatchObject({
69
+ message: {
70
+ context: {
71
+ reply: {
72
+ reply_preview: {
73
+ id: "chat-1",
74
+ nick_name: "User 1",
75
+ },
76
+ },
77
+ },
78
+ },
79
+ });
80
+ });
81
+
6
82
  it("emits message.failed in stream mode even if execution errors before any stream chunk", async () => {
7
83
  let hooks:
8
84
  | {
@@ -61,7 +137,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
61
137
  expect(sent[0]!.event).toBe("message.failed");
62
138
  expect(sent[0]!.payload.reason).toBe("Error: boom");
63
139
  expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
64
- ["chat-1", false, "direct"],
140
+ ["chat-1", false],
65
141
  ]);
66
142
  });
67
143
 
@@ -186,10 +262,10 @@ describe("openclaw-clawchat reply-dispatcher", () => {
186
262
  expect(client.sendMessage).toHaveBeenCalledWith(
187
263
  expect.objectContaining({
188
264
  chat_id: "chat-1",
189
- chat_type: "direct",
190
265
  body: { fragments: [{ kind: "text", text: "Error: boom" }] },
191
266
  }),
192
267
  );
268
+ expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
193
269
  expect(client.replyMessage).not.toHaveBeenCalled();
194
270
  });
195
271
 
@@ -246,9 +322,348 @@ describe("openclaw-clawchat reply-dispatcher", () => {
246
322
  expect(client.sendMessage).toHaveBeenCalledWith(
247
323
  expect.objectContaining({
248
324
  chat_id: "chat-1",
249
- chat_type: "direct",
250
325
  body: { fragments: [{ kind: "text", text: "Error: boom" }] },
251
326
  }),
252
327
  );
328
+ expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
329
+ });
330
+
331
+ it("emits approval rich fragments with fallback_text when rich interactions are enabled", async () => {
332
+ let hooks:
333
+ | {
334
+ deliver?: (payload: {
335
+ text?: string;
336
+ presentation?: {
337
+ title?: string;
338
+ tone?: string;
339
+ blocks: Array<
340
+ | { type: "text"; text: string }
341
+ | { type: "buttons"; buttons: Array<{ label: string; value?: string; style?: string }> }
342
+ >;
343
+ };
344
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
345
+ }
346
+ | undefined;
347
+ const client = {
348
+ sendMessage: vi.fn().mockResolvedValue({
349
+ payload: { message_id: "server-m1", accepted_at: 1234 },
350
+ trace_id: "trace-rich",
351
+ }),
352
+ replyMessage: vi.fn(),
353
+ typing: vi.fn(),
354
+ } as unknown as ClawlingChatClient;
355
+
356
+ createOpenclawClawlingReplyDispatcher({
357
+ cfg: {} as never,
358
+ runtime: {
359
+ channel: {
360
+ reply: {
361
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
362
+ createReplyDispatcherWithTyping: vi.fn((options) => {
363
+ hooks = options;
364
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
365
+ }),
366
+ },
367
+ },
368
+ } as never,
369
+ account: {
370
+ accountId: "default",
371
+ userId: "agent-1",
372
+ replyMode: "static",
373
+ forwardThinking: true,
374
+ forwardToolCalls: false,
375
+ richInteractions: true,
376
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
377
+ } as never,
378
+ client,
379
+ target: { chatId: "chat-1", chatType: "direct" },
380
+ log: { info: vi.fn(), error: vi.fn() },
381
+ });
382
+
383
+ await hooks?.deliver?.(
384
+ {
385
+ text: "Approve file deletion?",
386
+ presentation: {
387
+ title: "Approval required",
388
+ tone: "warning",
389
+ blocks: [
390
+ { type: "text", text: "Delete /tmp/example.txt?" },
391
+ {
392
+ type: "buttons",
393
+ buttons: [
394
+ { label: "Approve", value: "approve", style: "primary" },
395
+ { label: "Deny", value: "deny", style: "danger" },
396
+ ],
397
+ },
398
+ ],
399
+ },
400
+ },
401
+ { kind: "final" },
402
+ );
403
+
404
+ expect(client.sendMessage).toHaveBeenCalledWith(
405
+ expect.objectContaining({
406
+ body: {
407
+ fragments: [
408
+ {
409
+ kind: "approval_request",
410
+ title: "Approval required",
411
+ fallback_text: expect.stringContaining("Delete /tmp/example.txt?"),
412
+ state: "pending",
413
+ actions: [
414
+ { id: "approve", label: "Approve", style: "primary", payload: { value: "approve" } },
415
+ { id: "deny", label: "Deny", style: "danger", payload: { value: "deny" } },
416
+ ],
417
+ },
418
+ ],
419
+ },
420
+ }),
421
+ );
422
+ });
423
+
424
+ it("sends plain fallback text for presentations when rich interactions are disabled", async () => {
425
+ let hooks:
426
+ | {
427
+ deliver?: (payload: {
428
+ text?: string;
429
+ presentation?: {
430
+ title?: string;
431
+ blocks: Array<{ type: "text"; text: string }>;
432
+ };
433
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
434
+ }
435
+ | undefined;
436
+ const client = {
437
+ sendMessage: vi.fn().mockResolvedValue({
438
+ payload: { message_id: "server-m1", accepted_at: 1234 },
439
+ trace_id: "trace-fallback",
440
+ }),
441
+ replyMessage: vi.fn(),
442
+ typing: vi.fn(),
443
+ } as unknown as ClawlingChatClient;
444
+
445
+ createOpenclawClawlingReplyDispatcher({
446
+ cfg: {} as never,
447
+ runtime: {
448
+ channel: {
449
+ reply: {
450
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
451
+ createReplyDispatcherWithTyping: vi.fn((options) => {
452
+ hooks = options;
453
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
454
+ }),
455
+ },
456
+ },
457
+ } as never,
458
+ account: {
459
+ accountId: "default",
460
+ userId: "agent-1",
461
+ replyMode: "static",
462
+ forwardThinking: true,
463
+ forwardToolCalls: false,
464
+ richInteractions: false,
465
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
466
+ } as never,
467
+ client,
468
+ target: { chatId: "chat-1", chatType: "direct" },
469
+ log: { info: vi.fn(), error: vi.fn() },
470
+ });
471
+
472
+ await hooks?.deliver?.(
473
+ {
474
+ text: "Approve file deletion?",
475
+ presentation: {
476
+ title: "Approval required",
477
+ blocks: [{ type: "text", text: "Delete /tmp/example.txt?" }],
478
+ },
479
+ },
480
+ { kind: "final" },
481
+ );
482
+
483
+ expect(client.sendMessage).toHaveBeenCalledWith(
484
+ expect.objectContaining({
485
+ body: {
486
+ fragments: [{ kind: "text", text: expect.stringContaining("Delete /tmp/example.txt?") }],
487
+ },
488
+ }),
489
+ );
490
+ });
491
+
492
+ it("includes rich interaction fragments in the consolidated streaming final reply", async () => {
493
+ let hooks:
494
+ | {
495
+ deliver?: (payload: {
496
+ text?: string;
497
+ presentation?: {
498
+ title?: string;
499
+ tone?: string;
500
+ blocks: Array<
501
+ | { type: "text"; text: string }
502
+ | { type: "buttons"; buttons: Array<{ label: string; value?: string }> }
503
+ >;
504
+ };
505
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
506
+ onIdle?: () => Promise<void>;
507
+ }
508
+ | undefined;
509
+ const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
510
+ const client = {
511
+ opts: {
512
+ transport: {
513
+ send: (data: string) => {
514
+ const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
515
+ sent.push({ event: env.event, payload: env.payload });
516
+ },
517
+ },
518
+ traceIdFactory: () => "trace-stream-rich",
519
+ },
520
+ typing: vi.fn(),
521
+ } as unknown as ClawlingChatClient;
522
+
523
+ createOpenclawClawlingReplyDispatcher({
524
+ cfg: {} as never,
525
+ runtime: {
526
+ channel: {
527
+ reply: {
528
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
529
+ createReplyDispatcherWithTyping: vi.fn((options) => {
530
+ hooks = options;
531
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
532
+ }),
533
+ },
534
+ },
535
+ } as never,
536
+ account: {
537
+ accountId: "default",
538
+ userId: "agent-1",
539
+ replyMode: "stream",
540
+ forwardThinking: true,
541
+ forwardToolCalls: false,
542
+ richInteractions: true,
543
+ stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
544
+ } as never,
545
+ client,
546
+ target: { chatId: "chat-1", chatType: "direct" },
547
+ inboundMessageId: "inbound-1",
548
+ inboundForFinalReply: {
549
+ chatId: "chat-1",
550
+ senderId: "user-1",
551
+ senderNickName: "User 1",
552
+ bodyText: "hello",
553
+ },
554
+ log: { info: vi.fn(), error: vi.fn() },
555
+ });
556
+
557
+ await hooks?.deliver?.(
558
+ {
559
+ presentation: {
560
+ title: "Approval required",
561
+ tone: "warning",
562
+ blocks: [
563
+ { type: "text", text: "Run deploy?" },
564
+ { type: "buttons", buttons: [{ label: "Approve", value: "approve" }] },
565
+ ],
566
+ },
567
+ },
568
+ { kind: "final" },
569
+ );
570
+ await hooks?.onIdle?.();
571
+
572
+ const finalReply = sent.find((entry) => entry.event === "message.reply");
573
+ expect(finalReply?.payload).toMatchObject({
574
+ message: {
575
+ body: {
576
+ fragments: [
577
+ {
578
+ kind: "approval_request",
579
+ title: "Approval required",
580
+ fallback_text: expect.stringContaining("Run deploy?"),
581
+ state: "pending",
582
+ },
583
+ ],
584
+ },
585
+ },
586
+ });
587
+ });
588
+
589
+ it("forwards non-final rich block payloads in static mode", async () => {
590
+ let hooks:
591
+ | {
592
+ deliver?: (payload: {
593
+ text?: string;
594
+ presentation?: {
595
+ title?: string;
596
+ blocks: Array<
597
+ | { type: "text"; text: string }
598
+ | { type: "buttons"; buttons: Array<{ label: string; value?: string }> }
599
+ >;
600
+ };
601
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
602
+ }
603
+ | undefined;
604
+ const client = {
605
+ sendMessage: vi.fn().mockResolvedValue({
606
+ payload: { message_id: "server-m1", accepted_at: 1234 },
607
+ trace_id: "trace-block-rich",
608
+ }),
609
+ replyMessage: vi.fn(),
610
+ typing: vi.fn(),
611
+ } as unknown as ClawlingChatClient;
612
+
613
+ createOpenclawClawlingReplyDispatcher({
614
+ cfg: {} as never,
615
+ runtime: {
616
+ channel: {
617
+ reply: {
618
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
619
+ createReplyDispatcherWithTyping: vi.fn((options) => {
620
+ hooks = options;
621
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
622
+ }),
623
+ },
624
+ },
625
+ } as never,
626
+ account: {
627
+ accountId: "default",
628
+ userId: "agent-1",
629
+ replyMode: "static",
630
+ forwardThinking: true,
631
+ forwardToolCalls: false,
632
+ richInteractions: true,
633
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
634
+ } as never,
635
+ client,
636
+ target: { chatId: "chat-1", chatType: "direct" },
637
+ log: { info: vi.fn(), error: vi.fn() },
638
+ });
639
+
640
+ await hooks?.deliver?.(
641
+ {
642
+ presentation: {
643
+ title: "Choose next step",
644
+ blocks: [
645
+ { type: "text", text: "Pick an action" },
646
+ { type: "buttons", buttons: [{ label: "Continue", value: "continue" }] },
647
+ ],
648
+ },
649
+ },
650
+ { kind: "block" },
651
+ );
652
+
653
+ expect(client.sendMessage).toHaveBeenCalledWith(
654
+ expect.objectContaining({
655
+ body: {
656
+ fragments: [
657
+ {
658
+ kind: "action_card",
659
+ title: "Choose next step",
660
+ fallback_text: expect.stringContaining("Pick an action"),
661
+ state: "pending",
662
+ actions: [{ id: "continue", label: "Continue", payload: { value: "continue" } }],
663
+ },
664
+ ],
665
+ },
666
+ }),
667
+ );
253
668
  });
254
669
  });
@@ -1,4 +1,11 @@
1
1
  import type { ClawlingChatClient, Fragment } from "@newbase-clawchat/sdk";
2
+ import {
3
+ interactiveReplyToPresentation,
4
+ renderMessagePresentationFallbackText,
5
+ type MessagePresentation,
6
+ type MessagePresentationBlock,
7
+ type MessagePresentationButtonStyle,
8
+ } from "openclaw/plugin-sdk/interactive-runtime";
2
9
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
3
10
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
11
  import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
@@ -39,6 +46,7 @@ export interface ReplyDispatcherOptions {
39
46
  * the consolidated `message.reply` that closes a streaming run.
40
47
  */
41
48
  inboundForFinalReply?: {
49
+ chatId?: string;
42
50
  senderId: string;
43
51
  senderNickName: string;
44
52
  bodyText: string;
@@ -55,6 +63,22 @@ type StreamingReplyHooks = {
55
63
  onReasoningStream?: (payload: ReplyPayload) => Promise<void>;
56
64
  };
57
65
 
66
+ type RichAction = {
67
+ id: string;
68
+ label: string;
69
+ style?: MessagePresentationButtonStyle;
70
+ disabled?: boolean;
71
+ payload?: Record<string, unknown>;
72
+ };
73
+
74
+ type RichInteractionFragment = {
75
+ kind: "approval_request" | "action_card";
76
+ title?: string;
77
+ fallback_text: string;
78
+ state: "pending";
79
+ actions: RichAction[];
80
+ };
81
+
58
82
  function normalizeReplyErrorText(error: unknown): string {
59
83
  const raw = String(error);
60
84
  const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
@@ -64,6 +88,86 @@ function normalizeReplyErrorText(error: unknown): string {
64
88
  return raw;
65
89
  }
66
90
 
91
+ function isMessagePresentation(value: unknown): value is MessagePresentation {
92
+ return Boolean(
93
+ value &&
94
+ typeof value === "object" &&
95
+ Array.isArray((value as { blocks?: unknown }).blocks),
96
+ );
97
+ }
98
+
99
+ function resolvePresentation(payload: ReplyPayload): MessagePresentation | undefined {
100
+ if (isMessagePresentation(payload.presentation)) return payload.presentation;
101
+ if (payload.interactive) return interactiveReplyToPresentation(payload.interactive);
102
+ return undefined;
103
+ }
104
+
105
+ function normalizeActionId(value: string | undefined, label: string, index: number): string {
106
+ const raw = value?.trim() || label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-");
107
+ return raw.replace(/^-+|-+$/g, "") || `action-${index + 1}`;
108
+ }
109
+
110
+ function collectPresentationActions(blocks: MessagePresentationBlock[]): RichAction[] {
111
+ const actions: RichAction[] = [];
112
+ for (const block of blocks) {
113
+ if (block.type === "buttons") {
114
+ for (const button of block.buttons) {
115
+ const value = button.value?.trim();
116
+ const url = button.url?.trim();
117
+ const action: RichAction = {
118
+ id: normalizeActionId(value ?? url, button.label, actions.length),
119
+ label: button.label,
120
+ ...(button.style ? { style: button.style } : {}),
121
+ ...(value || url ? { payload: { ...(value ? { value } : {}), ...(url ? { url } : {}) } } : {}),
122
+ };
123
+ actions.push(action);
124
+ }
125
+ }
126
+ if (block.type === "select") {
127
+ for (const option of block.options) {
128
+ actions.push({
129
+ id: normalizeActionId(option.value, option.label, actions.length),
130
+ label: option.label,
131
+ style: "secondary",
132
+ payload: { value: option.value },
133
+ });
134
+ }
135
+ }
136
+ }
137
+ return actions;
138
+ }
139
+
140
+ function looksLikeApproval(actions: RichAction[], presentation: MessagePresentation): boolean {
141
+ if (presentation.tone === "warning" || presentation.tone === "danger") return true;
142
+ const ids = new Set(actions.map((action) => action.id.toLowerCase()));
143
+ return ids.has("approve") || ids.has("deny") || ids.has("reject");
144
+ }
145
+
146
+ function buildRichInteractionFragment(payload: ReplyPayload): RichInteractionFragment | null {
147
+ const presentation = resolvePresentation(payload);
148
+ if (!presentation) return null;
149
+ const actions = collectPresentationActions(presentation.blocks);
150
+ if (actions.length === 0) return null;
151
+ const fallbackText = renderMessagePresentationFallbackText({
152
+ presentation,
153
+ text: payload.text ?? null,
154
+ }).trim();
155
+ if (!fallbackText) return null;
156
+ return {
157
+ kind: looksLikeApproval(actions, presentation) ? "approval_request" : "action_card",
158
+ ...(presentation.title?.trim() ? { title: presentation.title.trim() } : {}),
159
+ fallback_text: fallbackText,
160
+ state: "pending",
161
+ actions,
162
+ };
163
+ }
164
+
165
+ function resolvePayloadText(payload: ReplyPayload): string {
166
+ const presentation = resolvePresentation(payload);
167
+ if (!presentation) return payload.text ?? "";
168
+ return renderMessagePresentationFallbackText({ presentation, text: payload.text ?? null });
169
+ }
170
+
67
171
  /**
68
172
  * Reply dispatcher for openclaw-clawchat.
69
173
  *
@@ -134,6 +238,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
134
238
  let streamText = "";
135
239
  let reasoningText = "";
136
240
  const accumulatedMediaUrls: string[] = [];
241
+ const finalRichFragments: Fragment[] = [];
137
242
  let finalEmitted = false;
138
243
  let streamingClosed = false;
139
244
  let runFailed = false;
@@ -206,10 +311,14 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
206
311
 
207
312
  // ----- Static send ------------------------------------------------------
208
313
 
209
- const sendStatic = async (text: string, mediaFragments: ClawlingMediaFragment[] = []) => {
210
- if (!text.trim() && mediaFragments.length === 0) return;
314
+ const sendStatic = async (
315
+ text: string,
316
+ mediaFragments: ClawlingMediaFragment[] = [],
317
+ richFragments: Fragment[] = [],
318
+ ) => {
319
+ if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0) return;
211
320
  log?.info?.(
212
- `[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} to=${target.chatId}`,
321
+ `[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`,
213
322
  );
214
323
  await sendOpenclawClawlingText({
215
324
  client,
@@ -217,6 +326,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
217
326
  to: target,
218
327
  text,
219
328
  ...(replyCtx ? { replyCtx } : {}),
329
+ ...(richFragments.length > 0 ? { richFragments } : {}),
220
330
  ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
221
331
  log,
222
332
  });
@@ -230,7 +340,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
230
340
  finalEmitted = true;
231
341
  const mergedMedia = await uploadMediaUrls(accumulatedMediaUrls.slice());
232
342
  const mergedText = streamText.trim();
233
- if (!mergedText && mergedMedia.length === 0) {
343
+ if (!mergedText && finalRichFragments.length === 0 && mergedMedia.length === 0) {
234
344
  log?.info?.(
235
345
  `[${account.accountId}] openclaw-clawchat no merged final content; skip consolidated reply`,
236
346
  );
@@ -241,6 +351,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
241
351
  );
242
352
  const bodyFragments: Fragment[] = [
243
353
  ...(mergedText ? textToFragments(mergedText) : []),
354
+ ...finalRichFragments,
244
355
  // mediaFragments is the local wide shape; cast at SDK boundary as
245
356
  // we do in outbound.ts.
246
357
  ...(mergedMedia as Fragment[]),
@@ -252,7 +363,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
252
363
  routing,
253
364
  replyTo: {
254
365
  msgId: inboundMessageId ?? streamingMessageId,
255
- senderId: inboundForFinalReply?.senderId ?? target.chatId,
366
+ previewId: inboundForFinalReply?.chatId ?? target.chatId,
256
367
  nickName:
257
368
  inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
258
369
  fragments: inboundForFinalReply?.bodyText
@@ -263,8 +374,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
263
374
  });
264
375
  };
265
376
 
266
- const ingestFinalPayload = (payload: ReplyPayload) => {
267
- const text = payload.text ?? "";
377
+ const ingestFinalPayload = (payload: ReplyPayload, text: string, richFragment: Fragment | null) => {
378
+ if (richFragment && account.richInteractions) {
379
+ finalRichFragments.push(richFragment);
380
+ }
268
381
  if (text) streamText = mergeStreamingText(streamText, text);
269
382
  const urls = [
270
383
  ...(payload.mediaUrl ? [payload.mediaUrl] : []),
@@ -300,13 +413,15 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
300
413
  streamText = "";
301
414
  reasoningText = "";
302
415
  accumulatedMediaUrls.length = 0;
416
+ finalRichFragments.length = 0;
303
417
  finalEmitted = false;
304
418
  streamingClosed = false;
305
419
  runDone = false;
306
420
  }
307
421
  },
308
422
  deliver: async (payload: ReplyPayload, info?: { kind: "tool" | "block" | "final" }) => {
309
- const text = payload.text ?? "";
423
+ const richFragment = buildRichInteractionFragment(payload);
424
+ const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
310
425
  const urls = [
311
426
  ...(payload.mediaUrl ? [payload.mediaUrl] : []),
312
427
  ...(payload.mediaUrls ?? []),
@@ -329,12 +444,20 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
329
444
  if (info?.kind === "tool" && !account.forwardToolCalls) return;
330
445
 
331
446
  if (info?.kind === "final") {
332
- ingestFinalPayload(payload);
447
+ ingestFinalPayload(
448
+ payload,
449
+ text,
450
+ richFragment && account.richInteractions ? (richFragment as unknown as Fragment) : null,
451
+ );
333
452
  // For streaming: consolidated final is emitted in onIdle after done.
334
453
  // For static: emit immediately.
335
454
  if (!streamingEnabled) {
336
455
  const mediaFragments = await uploadMediaUrls(urls);
337
- await sendStatic(text, mediaFragments);
456
+ await sendStatic(
457
+ text,
458
+ mediaFragments,
459
+ richFragment && account.richInteractions ? ([richFragment] as unknown as Fragment[]) : [],
460
+ );
338
461
  }
339
462
  return;
340
463
  }
@@ -360,8 +483,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
360
483
  }
361
484
  } else {
362
485
  const mediaFragments = await uploadMediaUrls(urls);
363
- if (text.trim() || mediaFragments.length > 0) {
364
- await sendStatic(text, mediaFragments);
486
+ const richFragments =
487
+ richFragment && account.richInteractions ? ([richFragment] as unknown as Fragment[]) : [];
488
+ if (text.trim() || mediaFragments.length > 0 || richFragments.length > 0) {
489
+ await sendStatic(text, mediaFragments, richFragments);
365
490
  }
366
491
  }
367
492
  },