@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.
- package/README.md +37 -11
- 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 +200 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +226 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +132 -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 +21 -0
- package/package.json +27 -5
- package/skills/clawchat-activate/SKILL.md +18 -9
- 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.test.ts +7 -1
- package/src/channel.ts +27 -8
- 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 +47 -0
- package/src/config.ts +28 -5
- 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 +57 -27
- package/src/manifest.test.ts +156 -30
- package/src/outbound.test.ts +6 -5
- package/src/outbound.ts +8 -7
- package/src/plugin-entry.test.ts +7 -1
- 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 +65 -74
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
|
},
|
package/src/runtime.ts
CHANGED
|
@@ -262,6 +262,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
262
262
|
...(replyCtx ? { replyCtx } : {}),
|
|
263
263
|
inboundMessageId: turn.messageId,
|
|
264
264
|
inboundForFinalReply: {
|
|
265
|
+
chatId: turn.peer.id,
|
|
265
266
|
senderId: turn.senderId,
|
|
266
267
|
senderNickName: turn.senderNickName || turn.senderId,
|
|
267
268
|
bodyText: turn.rawBody,
|
package/src/streaming.test.ts
CHANGED
|
@@ -37,12 +37,12 @@ describe("openclaw-clawchat streaming", () => {
|
|
|
37
37
|
"message.done",
|
|
38
38
|
]);
|
|
39
39
|
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
40
|
-
["u1", true
|
|
41
|
-
["u1", false
|
|
40
|
+
["u1", true],
|
|
41
|
+
["u1", false],
|
|
42
42
|
]);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it("message.created payload is minimal (just message_id); adds carry monotonic sequences", async () => {
|
|
45
|
+
it("message.created payload is minimal (just message_id); adds carry zero-based monotonic sequences", async () => {
|
|
46
46
|
const { client, sent } = mockClient();
|
|
47
47
|
await sendStreamingText({
|
|
48
48
|
client,
|
|
@@ -54,10 +54,10 @@ describe("openclaw-clawchat streaming", () => {
|
|
|
54
54
|
|
|
55
55
|
// created payload is { message_id } only — no embedded message / streaming.
|
|
56
56
|
expect(sent[0]!.payload).toEqual({ message_id: "m1" });
|
|
57
|
-
expect(sent[1]!.payload.sequence).toBe(
|
|
58
|
-
expect(sent[2]!.payload.sequence).toBe(
|
|
59
|
-
expect(sent[3]!.payload.sequence).toBe(
|
|
60
|
-
expect((sent[4]!.payload.streaming as { sequence: number }).sequence).toBe(
|
|
57
|
+
expect(sent[1]!.payload.sequence).toBe(0);
|
|
58
|
+
expect(sent[2]!.payload.sequence).toBe(1);
|
|
59
|
+
expect(sent[3]!.payload.sequence).toBe(2);
|
|
60
|
+
expect((sent[4]!.payload.streaming as { sequence: number }).sequence).toBe(2);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
it("each message.add carries fragments: [{ text: cumulative, delta: new }]", async () => {
|
|
@@ -107,10 +107,13 @@ describe("openclaw-clawchat streaming", () => {
|
|
|
107
107
|
reason: "boom",
|
|
108
108
|
});
|
|
109
109
|
expect(sent[0]!.event).toBe("message.failed");
|
|
110
|
-
expect(sent[0]!.payload.sequence).toBe(
|
|
110
|
+
expect(sent[0]!.payload.sequence).toBe(2);
|
|
111
111
|
expect(sent[0]!.payload.reason).toBe("boom");
|
|
112
|
+
expect(sent[0]!.payload).toHaveProperty("completed_at");
|
|
113
|
+
expect(sent[0]!.payload).not.toHaveProperty("failed_at");
|
|
114
|
+
expect(sent[0]!.payload.fragments).toEqual([{ kind: "text", text: "boom" }]);
|
|
112
115
|
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
113
|
-
["u1", false
|
|
116
|
+
["u1", false],
|
|
114
117
|
]);
|
|
115
118
|
});
|
|
116
119
|
});
|
package/src/streaming.ts
CHANGED
|
@@ -45,13 +45,13 @@ export async function sendStreamingText(params: StreamingSendParams): Promise<vo
|
|
|
45
45
|
const routing = resolveRouting(params);
|
|
46
46
|
const emitTyping = params.emitTyping !== false;
|
|
47
47
|
if (emitTyping) {
|
|
48
|
-
params.client.typing(routing.chatId, true
|
|
48
|
+
params.client.typing(routing.chatId, true);
|
|
49
49
|
}
|
|
50
50
|
emitStreamCreated(params.client, {
|
|
51
51
|
messageId: params.messageId,
|
|
52
52
|
routing,
|
|
53
53
|
});
|
|
54
|
-
let sequence =
|
|
54
|
+
let sequence = -1;
|
|
55
55
|
let fullText = "";
|
|
56
56
|
for (const chunk of params.chunks) {
|
|
57
57
|
sequence += 1;
|
|
@@ -67,11 +67,11 @@ export async function sendStreamingText(params: StreamingSendParams): Promise<vo
|
|
|
67
67
|
emitStreamDone(params.client, {
|
|
68
68
|
messageId: params.messageId,
|
|
69
69
|
routing,
|
|
70
|
-
finalSequence: sequence,
|
|
70
|
+
finalSequence: Math.max(sequence, 0),
|
|
71
71
|
finalText: fullText,
|
|
72
72
|
});
|
|
73
73
|
if (emitTyping) {
|
|
74
|
-
params.client.typing(routing.chatId, false
|
|
74
|
+
params.client.typing(routing.chatId, false);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -90,10 +90,10 @@ export async function sendStreamingFailure(params: StreamingFailureParams): Prom
|
|
|
90
90
|
emitStreamFailed(params.client, {
|
|
91
91
|
messageId: params.messageId,
|
|
92
92
|
routing,
|
|
93
|
-
sequence: params.currentSequence
|
|
93
|
+
sequence: params.currentSequence,
|
|
94
94
|
reason: params.reason,
|
|
95
95
|
});
|
|
96
96
|
if (params.emitTyping !== false) {
|
|
97
|
-
params.client.typing(routing.chatId, false
|
|
97
|
+
params.client.typing(routing.chatId, false);
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/tools.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import { registerOpenclawClawlingTools } from "./tools.ts";
|
|
3
4
|
|
|
4
5
|
const loginRuntime = vi.hoisted(() => ({
|
|
@@ -12,6 +13,16 @@ interface RegisteredTool {
|
|
|
12
13
|
execute: (callId: string, params: unknown) => Promise<unknown>;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const ALWAYS_VISIBLE_TOOL_NAMES = [
|
|
17
|
+
"clawchat_activate",
|
|
18
|
+
"clawchat_get_account_profile",
|
|
19
|
+
"clawchat_get_user_profile",
|
|
20
|
+
"clawchat_list_account_friends",
|
|
21
|
+
"clawchat_update_account_profile",
|
|
22
|
+
"clawchat_upload_avatar_image",
|
|
23
|
+
"clawchat_upload_media_file",
|
|
24
|
+
];
|
|
25
|
+
|
|
15
26
|
function buildApi(opts: {
|
|
16
27
|
configChannel?: Record<string, unknown> | null;
|
|
17
28
|
configTools?: Record<string, unknown>;
|
|
@@ -32,6 +43,11 @@ function buildApi(opts: {
|
|
|
32
43
|
warn: vi.fn(),
|
|
33
44
|
error: vi.fn(),
|
|
34
45
|
},
|
|
46
|
+
runtime: {
|
|
47
|
+
config: {
|
|
48
|
+
mutateConfigFile: vi.fn(),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
35
51
|
registerTool: (tool: RegisteredTool, _options?: { name: string }) => {
|
|
36
52
|
registered.push(tool);
|
|
37
53
|
},
|
|
@@ -49,12 +65,22 @@ function configuredChannel(extra: Record<string, unknown> = {}) {
|
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
describe("registerOpenclawClawlingTools", () => {
|
|
52
|
-
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("uses OpenClaw SDK tool result types instead of direct pi-agent-core imports", () => {
|
|
73
|
+
const source = fs.readFileSync(new URL("./tools.ts", import.meta.url), "utf8");
|
|
74
|
+
expect(source).not.toMatch(/@mariozechner\/pi-agent-core/);
|
|
75
|
+
expect(source).toMatch(/openclaw\/plugin-sdk\/agent-harness-runtime/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("registers all ClawChat tools even when account.configured is false", () => {
|
|
53
79
|
const { api, registered } = buildApi({
|
|
54
80
|
configChannel: { websocketUrl: "wss://w" /* token / userId missing */ },
|
|
55
81
|
});
|
|
56
82
|
registerOpenclawClawlingTools(api);
|
|
57
|
-
expect(registered.map((t) => t.name)).toEqual(
|
|
83
|
+
expect(registered.map((t) => t.name).sort()).toEqual(ALWAYS_VISIBLE_TOOL_NAMES);
|
|
58
84
|
});
|
|
59
85
|
|
|
60
86
|
it("does not mutate tool policy during registration before account activation", () => {
|
|
@@ -65,22 +91,51 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
65
91
|
|
|
66
92
|
registerOpenclawClawlingTools(api);
|
|
67
93
|
|
|
68
|
-
expect(registered.map((t) => t.name)).toEqual(
|
|
94
|
+
expect(registered.map((t) => t.name).sort()).toEqual(ALWAYS_VISIBLE_TOOL_NAMES);
|
|
69
95
|
expect(api.config?.tools).toEqual({
|
|
70
96
|
profile: "coding",
|
|
71
97
|
allow: [],
|
|
72
98
|
});
|
|
73
99
|
});
|
|
74
100
|
|
|
75
|
-
it("
|
|
101
|
+
it("registers clawchat_activate for invite-code onboarding", async () => {
|
|
102
|
+
const { api, registered } = buildApi({
|
|
103
|
+
configChannel: { websocketUrl: "wss://w" /* token / userId missing */ },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
registerOpenclawClawlingTools(api);
|
|
107
|
+
|
|
108
|
+
const tool = registered.find((t) => t.name === "clawchat_activate");
|
|
109
|
+
expect(tool).toBeDefined();
|
|
110
|
+
loginRuntime.runOpenclawClawlingLogin.mockResolvedValueOnce(undefined);
|
|
111
|
+
|
|
112
|
+
const result = await tool!.execute("call-1", { code: "A1B2C3" });
|
|
113
|
+
|
|
114
|
+
expect(loginRuntime.runOpenclawClawlingLogin).toHaveBeenCalledTimes(1);
|
|
115
|
+
const params = loginRuntime.runOpenclawClawlingLogin.mock.calls[0]?.[0];
|
|
116
|
+
expect(params.cfg).toBe(api.config);
|
|
117
|
+
expect(params.mutateConfigFile).toBe(api.runtime.config.mutateConfigFile);
|
|
118
|
+
await expect(params.readInviteCode()).resolves.toBe("A1B2C3");
|
|
119
|
+
const text = (result as { content: { text: string }[] }).content[0]!.text;
|
|
120
|
+
const parsed = JSON.parse(text) as { ok?: boolean; message?: string };
|
|
121
|
+
expect(parsed.ok).toBe(true);
|
|
122
|
+
expect(parsed.message).toMatch(/activated successfully/i);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("clawchat_activate rejects empty invite codes", async () => {
|
|
76
126
|
const { api, registered } = buildApi({
|
|
77
127
|
configChannel: { websocketUrl: "wss://w" /* token / userId missing */ },
|
|
78
128
|
});
|
|
79
129
|
|
|
80
130
|
registerOpenclawClawlingTools(api);
|
|
131
|
+
const tool = registered.find((t) => t.name === "clawchat_activate")!;
|
|
132
|
+
const result = await tool.execute("call-1", { code: " " });
|
|
81
133
|
|
|
82
|
-
expect(registered.some((t) => t.name === "clawchat_activate")).toBe(false);
|
|
83
134
|
expect(loginRuntime.runOpenclawClawlingLogin).not.toHaveBeenCalled();
|
|
135
|
+
const text = (result as { content: { text: string }[] }).content[0]!.text;
|
|
136
|
+
const parsed = JSON.parse(text) as { error?: string; message?: string };
|
|
137
|
+
expect(parsed.error).toBe("validation");
|
|
138
|
+
expect(parsed.message).toMatch(/code is required/i);
|
|
84
139
|
});
|
|
85
140
|
|
|
86
141
|
it("skips registration when api.config is undefined", () => {
|
|
@@ -89,20 +144,13 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
89
144
|
expect(registered).toHaveLength(0);
|
|
90
145
|
});
|
|
91
146
|
|
|
92
|
-
it("registers all
|
|
147
|
+
it("registers all seven ClawChat tools when configured (regardless of baseUrl)", () => {
|
|
93
148
|
const { api, registered } = buildApi({
|
|
94
149
|
configChannel: configuredChannel(/* no baseUrl */),
|
|
95
150
|
});
|
|
96
151
|
registerOpenclawClawlingTools(api);
|
|
97
152
|
const names = registered.map((t) => t.name).sort();
|
|
98
|
-
expect(names).toEqual(
|
|
99
|
-
"clawchat_get_account_profile",
|
|
100
|
-
"clawchat_get_user_profile",
|
|
101
|
-
"clawchat_list_account_friends",
|
|
102
|
-
"clawchat_update_account_profile",
|
|
103
|
-
"clawchat_upload_avatar_image",
|
|
104
|
-
"clawchat_upload_media_file",
|
|
105
|
-
]);
|
|
153
|
+
expect(names).toEqual(ALWAYS_VISIBLE_TOOL_NAMES);
|
|
106
154
|
});
|
|
107
155
|
|
|
108
156
|
it("logs configured tool registration at debug level only", () => {
|
|
@@ -118,16 +166,31 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
118
166
|
|
|
119
167
|
expect(logger.info).not.toHaveBeenCalled();
|
|
120
168
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
121
|
-
"openclaw-clawchat: registered
|
|
169
|
+
"openclaw-clawchat: registered 7 clawchat_* tools (activate, get_account_profile, get_user_profile, list_account_friends, update_account_profile, upload_avatar_image, upload_media_file)",
|
|
122
170
|
);
|
|
123
171
|
});
|
|
124
172
|
|
|
125
|
-
it("
|
|
173
|
+
it("registers clawchat_activate when configured", () => {
|
|
126
174
|
const { api, registered } = buildApi({
|
|
127
175
|
configChannel: configuredChannel(),
|
|
128
176
|
});
|
|
129
177
|
registerOpenclawClawlingTools(api);
|
|
130
|
-
expect(registered.some((t) => t.name === "clawchat_activate")).toBe(
|
|
178
|
+
expect(registered.some((t) => t.name === "clawchat_activate")).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("account tools return a config error before activation instead of disappearing", async () => {
|
|
182
|
+
const { api, registered } = buildApi({
|
|
183
|
+
configChannel: { websocketUrl: "wss://w" /* token / userId missing */ },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
registerOpenclawClawlingTools(api);
|
|
187
|
+
const tool = registered.find((t) => t.name === "clawchat_get_account_profile")!;
|
|
188
|
+
const result = await tool.execute("call-1", {});
|
|
189
|
+
|
|
190
|
+
const text = (result as { content: { text: string }[] }).content[0]!.text;
|
|
191
|
+
const parsed = JSON.parse(text) as { error?: string; message?: string };
|
|
192
|
+
expect(parsed.error).toBe("config");
|
|
193
|
+
expect(parsed.message).toMatch(/token is required/i);
|
|
131
194
|
});
|
|
132
195
|
|
|
133
196
|
it("clawchat_update_account_profile description names account profile triggers (EN + ZH)", () => {
|