@clawling/clawchat-plugin-openclaw 2026.5.12-28

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 (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
@@ -0,0 +1,477 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { readClawChatMemoryFile } from "./clawchat-memory.ts";
6
+ import {
7
+ pullGroupMetadata,
8
+ pullOwnerMetadata,
9
+ pullUserMetadata,
10
+ pushMetadata,
11
+ updateMetadata,
12
+ } from "./clawchat-metadata.ts";
13
+
14
+ let roots: string[] = [];
15
+
16
+ function tempRoot(): string {
17
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawchat-plugin-openclaw-metadata-"));
18
+ roots.push(root);
19
+ return root;
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
24
+ roots = [];
25
+ });
26
+
27
+ describe("clawchat metadata mapping", () => {
28
+ it("combines agent detail behavior with owner user profile in owner.md", async () => {
29
+ const root = tempRoot();
30
+ const api = {
31
+ getAgentDetail: vi.fn().mockResolvedValue({
32
+ agent: {
33
+ id: "agent_row_1",
34
+ user_id: "usr_self",
35
+ owner_id: "usr_owner",
36
+ type: "agent",
37
+ nickname: "Hermes Agent",
38
+ avatar_url: "https://example.test/agent.png",
39
+ bio: "Agent line 1\r\nAgent line 2",
40
+ behavior: "Be concise\rBe kind",
41
+ visibility: "public",
42
+ status: "online",
43
+ raw: { ignored: true },
44
+ updated_at: "2026-05-24T10:00:00.000Z",
45
+ },
46
+ }),
47
+ getUserProfile: vi.fn().mockResolvedValue({
48
+ id: "usr_owner",
49
+ nickname: "Owner\nName",
50
+ avatar_url: "https://example.test/owner.png",
51
+ bio: "Owner line 1\r\nOwner line 2",
52
+ type: "user",
53
+ }),
54
+ };
55
+
56
+ const result = await pullOwnerMetadata({
57
+ memoryRoot: root,
58
+ agentId: "agent_row_1",
59
+ accountUserId: "usr_self",
60
+ accountOwnerUserId: "usr_owner",
61
+ api,
62
+ });
63
+
64
+ expect(result).toMatchObject({ ok: true, failures: [] });
65
+ expect(api.getAgentDetail).toHaveBeenCalledWith("agent_row_1");
66
+ expect(api.getUserProfile).toHaveBeenCalledWith("usr_owner");
67
+ await expect(readClawChatMemoryFile(root, { targetType: "owner", targetId: "owner" }))
68
+ .resolves.toMatchObject({
69
+ metadata: {
70
+ updated_at: "2026-05-24T10:00:00.000Z",
71
+ agent_id: "usr_self",
72
+ agent_owner_id: "usr_owner",
73
+ agent_nickname: "Hermes Agent",
74
+ agent_avatar_url: "https://example.test/agent.png",
75
+ agent_bio: "Agent line 1 Agent line 2",
76
+ agent_owner_nickname: "Owner Name",
77
+ agent_owner_avatar_url: "https://example.test/owner.png",
78
+ agent_owner_bio: "Owner line 1 Owner line 2",
79
+ agent_behavior: "Be concise Be kind",
80
+ },
81
+ });
82
+ const file = fs.readFileSync(path.join(root, "owner.md"), "utf8");
83
+ expect(file).toContain("agent_nickname: Hermes Agent");
84
+ expect(file).toContain("agent_bio: Agent line 1 Agent line 2");
85
+ expect(file).not.toContain("visibility");
86
+ expect(file).not.toContain("status");
87
+ expect(file).not.toContain("raw");
88
+ expect(file).not.toContain("type:");
89
+ });
90
+
91
+ it("maps user profile to users/<id>.md metadata fields and drops unknown fields", async () => {
92
+ const root = tempRoot();
93
+ const api = {
94
+ getUserProfile: vi.fn().mockResolvedValue({
95
+ id: "usr_peer",
96
+ type: "user",
97
+ nickname: "Peer",
98
+ avatar_url: "https://example.test/peer.png",
99
+ bio: "",
100
+ display_name: "Dropped display name",
101
+ kind: "dropped",
102
+ updated_at: "2026-05-24T10:01:00.000Z",
103
+ }),
104
+ };
105
+
106
+ await pullUserMetadata({ memoryRoot: root, userId: "usr_peer", api });
107
+
108
+ const file = await readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_peer" });
109
+ expect(file.metadata).toEqual({
110
+ updated_at: "2026-05-24T10:01:00.000Z",
111
+ id: "usr_peer",
112
+ nickname: "Peer",
113
+ avatar_url: "https://example.test/peer.png",
114
+ bio: "",
115
+ profile_type: "user",
116
+ });
117
+ });
118
+
119
+ it("maps conversation detail to groups/<id>.md participant ids and participant users", async () => {
120
+ const root = tempRoot();
121
+ const api = {
122
+ getConversation: vi.fn().mockResolvedValue({
123
+ conversation: {
124
+ id: "grp_1",
125
+ type: "group",
126
+ title: "Launch\nRoom",
127
+ description: "Planning\r\nNotes",
128
+ creator_id: "usr_owner",
129
+ created_at: "2026-05-24T09:59:00.000Z",
130
+ updated_at: "2026-05-24T10:02:00.000Z",
131
+ visibility: "private",
132
+ participants: [
133
+ {
134
+ conversation_id: "grp_1",
135
+ user_id: "usr_participant",
136
+ role: "member",
137
+ joined_at: "2026-05-24T10:03:00.000Z",
138
+ },
139
+ ],
140
+ },
141
+ }),
142
+ getUserProfile: vi.fn(async (userId: string) => userId === "usr_owner"
143
+ ? {
144
+ id: "usr_owner",
145
+ nickname: "Owner",
146
+ type: "user",
147
+ }
148
+ : {
149
+ id: "usr_participant",
150
+ nickname: "Participant",
151
+ avatar_url: "https://example.test/usr-participant.png",
152
+ bio: "Product lead",
153
+ type: "user",
154
+ }),
155
+ };
156
+
157
+ const result = await pullGroupMetadata({ memoryRoot: root, groupId: "grp_1", api });
158
+
159
+ expect(result).toMatchObject({ ok: true, failures: [] });
160
+ expect(api.getUserProfile).toHaveBeenCalledWith("usr_participant");
161
+ const groupFile = await readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_1" });
162
+ expect(groupFile.metadata).toEqual({
163
+ updated_at: "2026-05-24T10:02:00.000Z",
164
+ group_id: "grp_1",
165
+ group_type: "group",
166
+ group_title: "Launch Room",
167
+ group_description: "Planning Notes",
168
+ group_owner_id: "usr_owner",
169
+ group_owner_nickname: "Owner",
170
+ group_owner_profile_type: "user",
171
+ group_created_at: "2026-05-24T09:59:00.000Z",
172
+ participant_ids: "usr_participant",
173
+ });
174
+
175
+ await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_participant" }))
176
+ .resolves.toMatchObject({
177
+ metadata: {
178
+ id: "usr_participant",
179
+ nickname: "Participant",
180
+ avatar_url: "https://example.test/usr-participant.png",
181
+ bio: "Product lead",
182
+ profile_type: "user",
183
+ },
184
+ });
185
+ });
186
+
187
+ it("skips direct conversations when pulling group metadata", async () => {
188
+ const root = tempRoot();
189
+ const api = {
190
+ getConversation: vi.fn().mockResolvedValue({
191
+ conversation: {
192
+ id: "direct_1",
193
+ type: "direct",
194
+ title: "Owner DM",
195
+ participants: [{ user_id: "usr_owner", role: "owner" }],
196
+ },
197
+ }),
198
+ getUserProfile: vi.fn(),
199
+ };
200
+
201
+ const result = await pullGroupMetadata({ memoryRoot: root, groupId: "direct_1", api });
202
+
203
+ expect(result).toMatchObject({ ok: true, writes: [], failures: [] });
204
+ expect(result.conversation?.type).toBe("direct");
205
+ expect(api.getConversation).toHaveBeenCalledWith("direct_1");
206
+ expect(api.getUserProfile).not.toHaveBeenCalled();
207
+ await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "direct_1" }))
208
+ .resolves.toMatchObject({ exists: false });
209
+ await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_owner" }))
210
+ .resolves.toMatchObject({ exists: false });
211
+ });
212
+
213
+ it("skips owner and connected agent participants during group metadata pulls", async () => {
214
+ const root = tempRoot();
215
+ const api = {
216
+ getConversation: vi.fn().mockResolvedValue({
217
+ conversation: {
218
+ id: "grp_1",
219
+ type: "group",
220
+ participants: [
221
+ { user_id: "usr_owner", role: "owner" },
222
+ { user_id: "usr_self", role: "member" },
223
+ { user_id: "usr_peer", role: "member" },
224
+ ],
225
+ },
226
+ }),
227
+ getUserProfile: vi.fn(async (userId: string) => ({
228
+ id: userId,
229
+ nickname: `User ${userId}`,
230
+ type: "user",
231
+ })),
232
+ };
233
+
234
+ const result = await pullGroupMetadata({
235
+ memoryRoot: root,
236
+ groupId: "grp_1",
237
+ api,
238
+ skipUserIds: ["usr_owner", "usr_self"],
239
+ });
240
+
241
+ expect(result).toMatchObject({ ok: true, failures: [] });
242
+ expect(result.writes).toEqual([
243
+ { targetType: "group", targetId: "grp_1" },
244
+ { targetType: "user", targetId: "usr_peer" },
245
+ ]);
246
+ expect(api.getUserProfile).toHaveBeenCalledTimes(1);
247
+ expect(api.getUserProfile).toHaveBeenCalledWith("usr_peer");
248
+ await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_owner" }))
249
+ .resolves.toMatchObject({ exists: false });
250
+ await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_self" }))
251
+ .resolves.toMatchObject({ exists: false });
252
+ await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_peer" }))
253
+ .resolves.toMatchObject({
254
+ exists: true,
255
+ metadata: expect.objectContaining({ id: "usr_peer", nickname: "User usr_peer" }),
256
+ });
257
+ });
258
+
259
+ it("writes requested group target even when the response id differs", async () => {
260
+ const root = tempRoot();
261
+ const api = {
262
+ getConversation: vi.fn().mockResolvedValue({
263
+ conversation: {
264
+ id: "grp_response",
265
+ type: "group",
266
+ title: "Launch Room",
267
+ participants: [],
268
+ },
269
+ }),
270
+ };
271
+
272
+ await pullGroupMetadata({ memoryRoot: root, groupId: "grp_requested", api });
273
+
274
+ await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_requested" }))
275
+ .resolves.toMatchObject({
276
+ exists: true,
277
+ metadata: { group_id: "grp_requested", group_type: "group", group_title: "Launch Room" },
278
+ });
279
+ await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_response" }))
280
+ .resolves.toMatchObject({ exists: false });
281
+ });
282
+
283
+ it("does not overwrite an existing participant user metadata file during group pull", async () => {
284
+ const root = tempRoot();
285
+ fs.mkdirSync(path.join(root, "users"), { recursive: true });
286
+ fs.writeFileSync(
287
+ path.join(root, "users", "usr_participant.md"),
288
+ [
289
+ "<!-- clawchat:metadata:start -->",
290
+ "id: usr_participant",
291
+ "nickname: Existing",
292
+ "bio: Existing bio",
293
+ "<!-- clawchat:metadata:end -->",
294
+ "",
295
+ "Existing body.",
296
+ ].join("\n"),
297
+ );
298
+ const api = {
299
+ getConversation: vi.fn().mockResolvedValue({
300
+ conversation: {
301
+ id: "grp_1",
302
+ type: "group",
303
+ title: "Launch Room",
304
+ participants: [{ user_id: "usr_participant", role: "member" }],
305
+ },
306
+ }),
307
+ getUserProfile: vi.fn(),
308
+ };
309
+
310
+ await pullGroupMetadata({ memoryRoot: root, groupId: "grp_1", api });
311
+
312
+ expect(api.getUserProfile).not.toHaveBeenCalled();
313
+ await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_participant" }))
314
+ .resolves.toMatchObject({
315
+ metadata: { id: "usr_participant", nickname: "Existing", bio: "Existing bio" },
316
+ body: "Existing body.",
317
+ });
318
+ });
319
+
320
+ it("returns partial participant write failures without rolling back group metadata", async () => {
321
+ const root = tempRoot();
322
+ fs.symlinkSync(os.tmpdir(), path.join(root, "users"));
323
+ const api = {
324
+ getConversation: vi.fn().mockResolvedValue({
325
+ conversation: {
326
+ id: "grp_partial",
327
+ type: "group",
328
+ title: "Partial",
329
+ creator_id: "usr_owner",
330
+ created_at: "2026-05-24T09:59:00.000Z",
331
+ updated_at: "2026-05-24T10:02:00.000Z",
332
+ participants: [{ user_id: "usr_blocked", user: { id: "usr_blocked", nickname: "Blocked" } }],
333
+ },
334
+ }),
335
+ getUserProfile: vi.fn(async (userId: string) => {
336
+ if (userId === "usr_owner") return { id: "usr_owner", nickname: "Owner", type: "user" };
337
+ throw new Error("user unavailable");
338
+ }),
339
+ };
340
+
341
+ const result = await pullGroupMetadata({ memoryRoot: root, groupId: "grp_partial", api });
342
+
343
+ expect(result.ok).toBe(false);
344
+ expect(result.failures).toEqual([
345
+ expect.objectContaining({ targetType: "user", targetId: "usr_blocked" }),
346
+ ]);
347
+ await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_partial" }))
348
+ .resolves.toMatchObject({
349
+ metadata: expect.objectContaining({ group_id: "grp_partial", group_title: "Partial" }),
350
+ });
351
+ });
352
+ });
353
+
354
+ describe("clawchat metadata push and update", () => {
355
+ let root: string;
356
+
357
+ beforeEach(() => {
358
+ root = tempRoot();
359
+ });
360
+
361
+ it("pushes agent behavior from owner.md to the server before rewriting owner.md from owner profile", async () => {
362
+ fs.writeFileSync(
363
+ path.join(root, "owner.md"),
364
+ [
365
+ "<!-- clawchat:metadata:start -->",
366
+ "agent_owner_nickname: Local",
367
+ "agent_owner_avatar_url: https://example.test/local.png",
368
+ "agent_owner_bio: Local bio",
369
+ "agent_behavior: local-only",
370
+ "<!-- clawchat:metadata:end -->",
371
+ "",
372
+ "Keep body.",
373
+ ].join("\n"),
374
+ );
375
+ const api = {
376
+ updateAgentBehavior: vi.fn().mockResolvedValue({
377
+ agent: {
378
+ id: "agent_row_1",
379
+ user_id: "usr_self",
380
+ owner_id: "usr_owner",
381
+ nickname: "Agent Server",
382
+ avatar_url: "https://example.test/agent-server.png",
383
+ bio: "Agent server bio",
384
+ behavior: "Server behavior",
385
+ updated_at: "2026-05-24T10:05:00.000Z",
386
+ },
387
+ }),
388
+ getUserProfile: vi.fn().mockResolvedValue({
389
+ id: "usr_owner",
390
+ nickname: "Owner Server",
391
+ avatar_url: "https://example.test/owner-server.png",
392
+ bio: "Owner server bio",
393
+ }),
394
+ };
395
+
396
+ await pushMetadata({
397
+ memoryRoot: root,
398
+ targetType: "owner",
399
+ targetId: "owner",
400
+ fields: ["agent_behavior"],
401
+ agentId: "agent_row_1",
402
+ accountUserId: "usr_self",
403
+ api,
404
+ });
405
+
406
+ expect(api.updateAgentBehavior).toHaveBeenCalledWith("local-only");
407
+ expect(api.getUserProfile).toHaveBeenCalledWith("usr_owner");
408
+ await expect(readClawChatMemoryFile(root, { targetType: "owner", targetId: "owner" }))
409
+ .resolves.toMatchObject({
410
+ metadata: expect.objectContaining({
411
+ agent_owner_nickname: "Owner Server",
412
+ agent_owner_avatar_url: "https://example.test/owner-server.png",
413
+ agent_owner_bio: "Owner server bio",
414
+ agent_nickname: "Agent Server",
415
+ agent_avatar_url: "https://example.test/agent-server.png",
416
+ agent_bio: "Agent server bio",
417
+ agent_behavior: "Server behavior",
418
+ }),
419
+ body: "Keep body.",
420
+ });
421
+ });
422
+
423
+ it("rejects agent_owner_nickname push because owner display metadata is not agent metadata", async () => {
424
+ fs.writeFileSync(
425
+ path.join(root, "owner.md"),
426
+ [
427
+ "<!-- clawchat:metadata:start -->",
428
+ "agent_owner_nickname: Local Owner",
429
+ "<!-- clawchat:metadata:end -->",
430
+ "",
431
+ ].join("\n"),
432
+ );
433
+
434
+ await expect(pushMetadata({
435
+ memoryRoot: root,
436
+ targetType: "owner",
437
+ targetId: "owner",
438
+ fields: ["agent_owner_nickname"],
439
+ agentId: "agent_row_1",
440
+ accountUserId: "usr_self",
441
+ api: {},
442
+ })).rejects.toThrow("fields contain non-pushable metadata field: agent_owner_nickname");
443
+ });
444
+
445
+ it("updates group metadata through the server and leaves the local file unchanged on failure", async () => {
446
+ fs.mkdirSync(path.join(root, "groups"));
447
+ fs.writeFileSync(
448
+ path.join(root, "groups", "grp_1.md"),
449
+ [
450
+ "<!-- clawchat:metadata:start -->",
451
+ "group_id: grp_1",
452
+ "group_title: Old",
453
+ "<!-- clawchat:metadata:end -->",
454
+ "",
455
+ "Group body.",
456
+ ].join("\n"),
457
+ );
458
+ const before = fs.readFileSync(path.join(root, "groups", "grp_1.md"), "utf8");
459
+ const api = {
460
+ patchConversation: vi.fn().mockRejectedValue(new Error("server refused")),
461
+ };
462
+
463
+ await expect(updateMetadata({
464
+ memoryRoot: root,
465
+ targetType: "group",
466
+ targetId: "grp_1",
467
+ patch: { group_title: "New", group_description: "New description", visibility: "dropped" },
468
+ api,
469
+ })).rejects.toThrow("server refused");
470
+
471
+ expect(api.patchConversation).toHaveBeenCalledWith("grp_1", {
472
+ title: "New",
473
+ description: "New description",
474
+ });
475
+ expect(fs.readFileSync(path.join(root, "groups", "grp_1.md"), "utf8")).toBe(before);
476
+ });
477
+ });