@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.5.4

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 (49) hide show
  1. package/README.md +37 -11
  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 +200 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +226 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +132 -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 +21 -0
  22. package/package.json +27 -5
  23. package/skills/clawchat-activate/SKILL.md +18 -9
  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.test.ts +7 -1
  28. package/src/channel.ts +27 -8
  29. package/src/client.test.ts +8 -1
  30. package/src/client.ts +11 -10
  31. package/src/commands.test.ts +6 -0
  32. package/src/commands.ts +5 -1
  33. package/src/config.test.ts +47 -0
  34. package/src/config.ts +28 -5
  35. package/src/inbound.test.ts +4 -1
  36. package/src/inbound.ts +11 -10
  37. package/src/login.runtime.test.ts +36 -0
  38. package/src/login.runtime.ts +57 -27
  39. package/src/manifest.test.ts +156 -30
  40. package/src/outbound.test.ts +6 -5
  41. package/src/outbound.ts +8 -7
  42. package/src/plugin-entry.test.ts +7 -1
  43. package/src/reply-dispatcher.test.ts +418 -3
  44. package/src/reply-dispatcher.ts +137 -12
  45. package/src/runtime.ts +1 -0
  46. package/src/streaming.test.ts +12 -9
  47. package/src/streaming.ts +6 -6
  48. package/src/tools.test.ts +81 -18
  49. package/src/tools.ts +65 -74
package/src/outbound.ts CHANGED
@@ -22,6 +22,7 @@ export interface OutboundTarget {
22
22
 
23
23
  export interface OutboundReplyCtx {
24
24
  replyToMessageId: string;
25
+ replyPreviewChatId?: string;
25
26
  replyPreviewSenderId: string;
26
27
  replyPreviewNickName: string;
27
28
  replyPreviewText: string;
@@ -38,6 +39,7 @@ export interface SendParams {
38
39
  to: OutboundTarget;
39
40
  text: string;
40
41
  replyCtx?: OutboundReplyCtx;
42
+ richFragments?: Fragment[];
41
43
  mediaFragments?: ClawlingMediaFragment[];
42
44
  mentions?: string[];
43
45
  log?: LogSink;
@@ -92,8 +94,9 @@ export function parseOpenclawRecipient(to: string): { chatId: string; chatType:
92
94
 
93
95
  export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
94
96
  const text = (params.text ?? "").trim();
97
+ const richFragments = params.richFragments ?? [];
95
98
  const mediaFragments = params.mediaFragments ?? [];
96
- if (!text && mediaFragments.length === 0) {
99
+ if (!text && richFragments.length === 0 && mediaFragments.length === 0) {
97
100
  params.log?.info?.(
98
101
  `[${params.account.accountId}] openclaw-clawchat outbound suppressed: empty text and no media`,
99
102
  );
@@ -106,7 +109,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
106
109
  // with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
107
110
  // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
108
111
  // shape lets us build a single uniform array without a per-kind switch.
109
- const fragments = [...textFragments, ...mediaFragments] as Fragment[];
112
+ const fragments = [...textFragments, ...richFragments, ...mediaFragments] as Fragment[];
110
113
 
111
114
  const useReply = params.replyCtx && mediaFragments.length === 0;
112
115
  if (params.replyCtx && mediaFragments.length > 0) {
@@ -121,26 +124,24 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
121
124
  mode = "reply";
122
125
  ack = await params.client.replyMessage({
123
126
  chat_id: params.to.chatId,
124
- chat_type: params.to.chatType,
125
127
  mode: "normal",
126
128
  replyTo: {
127
129
  msgId: params.replyCtx.replyToMessageId,
128
- senderId: params.replyCtx.replyPreviewSenderId,
130
+ senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
129
131
  nickName: params.replyCtx.replyPreviewNickName,
130
132
  fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
131
133
  },
132
134
  body: { fragments },
133
135
  context: { mentions },
134
- });
136
+ } as Parameters<ClawlingChatClient["replyMessage"]>[0]);
135
137
  } else {
136
138
  mode = "send";
137
139
  ack = await params.client.sendMessage({
138
140
  chat_id: params.to.chatId,
139
- chat_type: params.to.chatType,
140
141
  mode: "normal",
141
142
  body: { fragments },
142
143
  context: { mentions, reply: null },
143
- });
144
+ } as Parameters<ClawlingChatClient["sendMessage"]>[0]);
144
145
  }
145
146
  params.log?.info?.(
146
147
  `[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`,
@@ -3,9 +3,14 @@ import pluginEntry from "../index.ts";
3
3
 
4
4
  describe("openclaw-clawchat plugin entry", () => {
5
5
  it("registers the channel/tools and native activation command without bootstrap migration", () => {
6
+ const mutateConfigFile = vi.fn();
6
7
  const api = {
7
8
  config: {},
8
- runtime: {},
9
+ runtime: {
10
+ config: {
11
+ mutateConfigFile,
12
+ },
13
+ },
9
14
  logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn() },
10
15
  registerChannel: vi.fn(),
11
16
  registerCommand: vi.fn(),
@@ -23,5 +28,6 @@ describe("openclaw-clawchat plugin entry", () => {
23
28
  name: "clawchat-login",
24
29
  }),
25
30
  );
31
+ expect(mutateConfigFile).not.toHaveBeenCalled();
26
32
  });
27
33
  });
@@ -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
  });