@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
@@ -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
  },
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,
@@ -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, "direct"],
41
- ["u1", false, "direct"],
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(1);
58
- expect(sent[2]!.payload.sequence).toBe(2);
59
- expect(sent[3]!.payload.sequence).toBe(3);
60
- expect((sent[4]!.payload.streaming as { sequence: number }).sequence).toBe(3);
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(3);
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, "direct"],
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, routing.chatType);
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 = 0;
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, routing.chatType);
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 + 1,
93
+ sequence: params.currentSequence,
94
94
  reason: params.reason,
95
95
  });
96
96
  if (params.emitTyping !== false) {
97
- params.client.typing(routing.chatId, false, routing.chatType);
97
+ params.client.typing(routing.chatId, false);
98
98
  }
99
99
  }
package/src/tools.test.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { describe, expect, it, vi } from "vitest";
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
- it("registers no tools when account.configured is false", () => {
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("does not register clawchat_activate for invite-code onboarding", async () => {
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 six account tools when configured (regardless of baseUrl)", () => {
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 6 clawchat_* tools (get_account_profile, get_user_profile, list_account_friends, update_account_profile, upload_avatar_image, upload_media_file)",
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("does not register clawchat_activate when configured", () => {
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(false);
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)", () => {