@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.
- package/README.md +33 -10
- package/dist/index.js +27 -0
- package/dist/src/api-client.js +156 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +191 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +214 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +130 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +82 -0
- package/dist/src/outbound.js +181 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/reply-dispatcher.js +440 -0
- package/dist/src/runtime.js +288 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/tools-schema.js +38 -0
- package/dist/src/tools.js +287 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +25 -5
- package/skills/clawchat-activate/SKILL.md +17 -8
- package/src/buffered-stream.test.ts +10 -0
- package/src/buffered-stream.ts +6 -6
- package/src/channel.outbound.test.ts +3 -3
- package/src/channel.ts +11 -3
- package/src/client.test.ts +8 -1
- package/src/client.ts +11 -10
- package/src/commands.test.ts +6 -0
- package/src/commands.ts +5 -1
- package/src/config.test.ts +3 -0
- package/src/config.ts +7 -0
- package/src/inbound.test.ts +4 -1
- package/src/inbound.ts +11 -10
- package/src/login.runtime.test.ts +36 -0
- package/src/login.runtime.ts +54 -26
- package/src/manifest.test.ts +98 -22
- package/src/outbound.test.ts +6 -5
- package/src/outbound.ts +8 -7
- package/src/reply-dispatcher.test.ts +418 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.ts +1 -0
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +6 -6
- package/src/tools.test.ts +81 -18
- 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
|
|
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
|
});
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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 (
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
364
|
-
|
|
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
|
},
|