@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13

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 (85) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +121 -19
  3. package/dist/index.js +10 -19
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +78 -10
  6. package/dist/src/api-types.test-d.js +10 -0
  7. package/dist/src/channel.js +25 -156
  8. package/dist/src/channel.setup.js +120 -0
  9. package/dist/src/client.js +37 -41
  10. package/dist/src/config.js +75 -17
  11. package/dist/src/inbound.js +79 -61
  12. package/dist/src/login.runtime.js +84 -19
  13. package/dist/src/media-runtime.js +8 -8
  14. package/dist/src/message-mapper.js +1 -1
  15. package/dist/src/mock-transport.js +31 -0
  16. package/dist/src/outbound.js +410 -26
  17. package/dist/src/protocol-types.js +63 -0
  18. package/dist/src/protocol-types.typecheck.js +1 -0
  19. package/dist/src/protocol.js +2 -7
  20. package/dist/src/reply-dispatcher.js +157 -54
  21. package/dist/src/runtime.js +795 -119
  22. package/dist/src/storage.js +689 -0
  23. package/dist/src/tools-schema.js +98 -16
  24. package/dist/src/tools.js +422 -135
  25. package/dist/src/ws-alignment.js +178 -0
  26. package/dist/src/ws-client.js +588 -0
  27. package/dist/src/ws-log.js +19 -0
  28. package/index.ts +10 -22
  29. package/openclaw.plugin.json +37 -2
  30. package/package.json +17 -4
  31. package/setup-entry.ts +4 -0
  32. package/skills/clawchat/SKILL.md +88 -0
  33. package/src/api-client.test.ts +274 -14
  34. package/src/api-client.ts +138 -23
  35. package/src/api-types.test-d.ts +12 -0
  36. package/src/api-types.ts +90 -4
  37. package/src/buffered-stream.test.ts +14 -12
  38. package/src/buffered-stream.ts +1 -1
  39. package/src/channel.outbound.test.ts +269 -60
  40. package/src/channel.setup.ts +146 -0
  41. package/src/channel.test.ts +130 -24
  42. package/src/channel.ts +30 -186
  43. package/src/client.test.ts +197 -11
  44. package/src/client.ts +50 -57
  45. package/src/config.test.ts +108 -6
  46. package/src/config.ts +95 -24
  47. package/src/inbound.test.ts +288 -37
  48. package/src/inbound.ts +96 -84
  49. package/src/login.runtime.test.ts +347 -13
  50. package/src/login.runtime.ts +105 -23
  51. package/src/manifest.test.ts +146 -74
  52. package/src/media-runtime.test.ts +57 -2
  53. package/src/media-runtime.ts +26 -17
  54. package/src/message-mapper.test.ts +2 -2
  55. package/src/message-mapper.ts +2 -2
  56. package/src/mock-transport.test.ts +35 -0
  57. package/src/mock-transport.ts +38 -0
  58. package/src/outbound.test.ts +694 -73
  59. package/src/outbound.ts +484 -31
  60. package/src/plugin-entry.test.ts +1 -0
  61. package/src/protocol-types.test.ts +69 -0
  62. package/src/protocol-types.ts +296 -0
  63. package/src/protocol-types.typecheck.ts +89 -0
  64. package/src/protocol.test.ts +1 -6
  65. package/src/protocol.ts +2 -7
  66. package/src/reply-dispatcher.test.ts +819 -119
  67. package/src/reply-dispatcher.ts +202 -60
  68. package/src/runtime.test.ts +2120 -41
  69. package/src/runtime.ts +935 -142
  70. package/src/scripts.test.ts +85 -0
  71. package/src/storage.test.ts +793 -0
  72. package/src/storage.ts +1095 -0
  73. package/src/streaming.test.ts +9 -8
  74. package/src/streaming.ts +1 -1
  75. package/src/tools-schema.ts +148 -20
  76. package/src/tools.test.ts +377 -50
  77. package/src/tools.ts +574 -154
  78. package/src/ws-alignment.test.ts +103 -0
  79. package/src/ws-alignment.ts +275 -0
  80. package/src/ws-client.test.ts +1218 -0
  81. package/src/ws-client.ts +662 -0
  82. package/src/ws-log.test.ts +32 -0
  83. package/src/ws-log.ts +31 -0
  84. package/skills/clawchat-account-tools/SKILL.md +0 -26
  85. package/skills/clawchat-activate/SKILL.md +0 -47
@@ -0,0 +1,793 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { createClawChatStore, getClawChatStore, resetClawChatStoreForTest } from "./storage.ts";
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ function tempDbPath(): string {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-clawchat-storage-"));
12
+ tempRoots.push(dir);
13
+ return path.join(dir, "clawchat.sqlite");
14
+ }
15
+
16
+ function readSqliteRows(dbPath: string, sql: string): Record<string, unknown>[] {
17
+ const db = new DatabaseSync(dbPath, { readOnly: true });
18
+ try {
19
+ return db.prepare(sql).all() as Record<string, unknown>[];
20
+ } finally {
21
+ db.close();
22
+ }
23
+ }
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ resetClawChatStoreForTest();
28
+ for (const dir of tempRoots.splice(0)) {
29
+ fs.rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ describe("clawchat sqlite storage", () => {
34
+ it("migrates connection metadata and conversation cache tables", () => {
35
+ const dbPath = tempDbPath();
36
+ const store = createClawChatStore({ dbPath });
37
+
38
+ store.initialize();
39
+ store.close();
40
+
41
+ expect(readSqliteRows(dbPath, "PRAGMA table_info(connections)").map((row) => row.name)).toEqual(
42
+ expect.arrayContaining(["resolved_device_id", "delivery_mode"]),
43
+ );
44
+ expect(
45
+ readSqliteRows(
46
+ dbPath,
47
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'clawchat_%' ORDER BY name",
48
+ ).map((row) => row.name),
49
+ ).toEqual(
50
+ expect.arrayContaining([
51
+ "clawchat_conversation_members",
52
+ "clawchat_conversations",
53
+ "clawchat_group_profiles",
54
+ "clawchat_user_profiles",
55
+ ]),
56
+ );
57
+ });
58
+
59
+ it("initializes schema and records activation/message/connection/tool rows", () => {
60
+ const store = createClawChatStore({ dbPath: tempDbPath() });
61
+
62
+ expect(store.listAppliedMigrations()).toEqual([
63
+ { version: 1, name: "initial_schema" },
64
+ { version: 2, name: "message_idempotency" },
65
+ { version: 3, name: "activation_bootstrap" },
66
+ { version: 4, name: "activation_owner_user_id" },
67
+ { version: 5, name: "conversation_cache" },
68
+ ]);
69
+
70
+ store.upsertActivation({
71
+ platform: "openclaw",
72
+ accountId: "default",
73
+ userId: "user-old",
74
+ ownerUserId: "owner-old",
75
+ accessToken: "token-old",
76
+ refreshToken: "refresh-old",
77
+ activatedAt: 1000,
78
+ loginMethod: "login",
79
+ });
80
+ store.upsertActivation({
81
+ platform: "openclaw",
82
+ accountId: "default",
83
+ userId: " user-new ",
84
+ ownerUserId: " owner-new ",
85
+ accessToken: "token-new",
86
+ refreshToken: null,
87
+ activatedAt: 2000,
88
+ loginMethod: "login",
89
+ });
90
+
91
+ expect(store.getActivationForTest("openclaw", "default")).toMatchObject({
92
+ user_id: "user-new",
93
+ owner_user_id: "owner-new",
94
+ access_token: null,
95
+ refresh_token: null,
96
+ activated_at: 2000,
97
+ updated_at: 2000,
98
+ });
99
+
100
+ store.insertMessage({
101
+ platform: "openclaw",
102
+ accountId: "default",
103
+ kind: "message",
104
+ direction: "outbound",
105
+ eventType: "message.send",
106
+ traceId: "trace-1",
107
+ chatId: "chat-1",
108
+ messageId: "msg-1",
109
+ text: "hello",
110
+ raw: { ok: true },
111
+ createdAt: 3000,
112
+ });
113
+
114
+ expect(store.listMessagesForTest()).toMatchObject([
115
+ { kind: "message", direction: "outbound", message_id: "msg-1", text: "hello" },
116
+ ]);
117
+
118
+ const connectionId = store.startConnection({
119
+ platform: "openclaw",
120
+ accountId: "default",
121
+ attempt: 1,
122
+ reconnectCount: 0,
123
+ connectStartedAt: 1000,
124
+ });
125
+ expect(connectionId).toEqual(expect.any(Number));
126
+ store.markConnectSent(connectionId, { at: 1100 });
127
+ store.markConnectionReady(connectionId, {
128
+ at: 1200,
129
+ resolvedDeviceId: "device-resolved",
130
+ deliveryMode: "realtime",
131
+ });
132
+ store.finishConnection(connectionId, { state: "disconnected", disconnectedAt: 1300 });
133
+
134
+ expect(store.listConnectionsForTest()).toMatchObject([
135
+ {
136
+ state: "disconnected",
137
+ connect_started_at: 1000,
138
+ connect_sent_at: 1100,
139
+ ready_at: 1200,
140
+ disconnected_at: 1300,
141
+ resolved_device_id: "device-resolved",
142
+ delivery_mode: "realtime",
143
+ },
144
+ ]);
145
+
146
+ store.recordToolCall({
147
+ platform: "openclaw",
148
+ accountId: "default",
149
+ toolName: "clawchat_get_account_profile",
150
+ args: { include: "profile" },
151
+ result: { id: "user-new" },
152
+ error: null,
153
+ startedAt: 4000,
154
+ endedAt: 4250,
155
+ });
156
+
157
+ expect(store.listToolCallsForTest()).toMatchObject([
158
+ { tool_name: "clawchat_get_account_profile", duration_ms: 250, error: null },
159
+ ]);
160
+
161
+ store.close();
162
+ });
163
+
164
+ it("preserves connection ready metadata when omitted on later ready updates", () => {
165
+ const store = createClawChatStore({ dbPath: tempDbPath() });
166
+ const connectionId = store.startConnection({
167
+ platform: "openclaw",
168
+ accountId: "default",
169
+ connectStartedAt: 1000,
170
+ });
171
+
172
+ store.markConnectionReady(connectionId, {
173
+ at: 1100,
174
+ resolvedDeviceId: "device-one",
175
+ deliveryMode: "realtime",
176
+ });
177
+ store.markConnectionReady(connectionId, { at: 1200 });
178
+
179
+ expect(store.listConnectionsForTest()).toMatchObject([
180
+ {
181
+ ready_at: 1200,
182
+ resolved_device_id: "device-one",
183
+ delivery_mode: "realtime",
184
+ },
185
+ ]);
186
+
187
+ store.close();
188
+ });
189
+
190
+ it("atomically claims actual messages by account direction and message_id", () => {
191
+ const store = createClawChatStore({ dbPath: tempDbPath() });
192
+
193
+ expect(store.listAppliedMigrations()).toEqual([
194
+ { version: 1, name: "initial_schema" },
195
+ { version: 2, name: "message_idempotency" },
196
+ { version: 3, name: "activation_bootstrap" },
197
+ { version: 4, name: "activation_owner_user_id" },
198
+ { version: 5, name: "conversation_cache" },
199
+ ]);
200
+
201
+ const first = store.claimMessageOnce({
202
+ platform: "openclaw",
203
+ accountId: "default",
204
+ kind: "message",
205
+ direction: "inbound",
206
+ eventType: "message.send",
207
+ traceId: "trace-1",
208
+ chatId: "chat-1",
209
+ messageId: "msg-unique",
210
+ text: "first",
211
+ raw: { ok: true },
212
+ createdAt: 3000,
213
+ });
214
+ const duplicate = store.claimMessageOnce({
215
+ platform: "openclaw",
216
+ accountId: "default",
217
+ kind: "message",
218
+ direction: "inbound",
219
+ eventType: "message.send",
220
+ traceId: "trace-2",
221
+ chatId: "chat-1",
222
+ messageId: "msg-unique",
223
+ text: "duplicate",
224
+ raw: { ok: true },
225
+ createdAt: 4000,
226
+ });
227
+ const outboundSameId = store.claimMessageOnce({
228
+ platform: "openclaw",
229
+ accountId: "default",
230
+ kind: "message",
231
+ direction: "outbound",
232
+ eventType: "message.reply",
233
+ traceId: "trace-outbound",
234
+ chatId: "chat-1",
235
+ messageId: "msg-unique",
236
+ text: "outbound",
237
+ raw: { ok: true },
238
+ createdAt: 4500,
239
+ });
240
+ const thinking = store.insertMessage({
241
+ platform: "openclaw",
242
+ accountId: "default",
243
+ kind: "thinking",
244
+ direction: "outbound",
245
+ eventType: "message.send",
246
+ traceId: "trace-thinking",
247
+ chatId: "chat-1",
248
+ messageId: "msg-unique",
249
+ text: "reasoning",
250
+ raw: { ok: true },
251
+ createdAt: 5000,
252
+ });
253
+
254
+ expect(first).toBe(true);
255
+ expect(duplicate).toBe(false);
256
+ expect(outboundSameId).toBe(true);
257
+ expect(thinking).toBe(true);
258
+ expect(store.listMessagesForTest()).toMatchObject([
259
+ { kind: "message", direction: "inbound", message_id: "msg-unique", text: "first" },
260
+ { kind: "message", direction: "outbound", message_id: "msg-unique", text: "outbound" },
261
+ { kind: "thinking", message_id: "msg-unique", text: "reasoning" },
262
+ ]);
263
+
264
+ store.close();
265
+ });
266
+
267
+ it("recreates the singleton when a later caller provides a different database path", () => {
268
+ const firstPath = tempDbPath();
269
+ const secondPath = tempDbPath();
270
+
271
+ const first = getClawChatStore({ dbPath: firstPath });
272
+ const second = getClawChatStore({ dbPath: secondPath });
273
+
274
+ expect(second.dbPath).toBe(secondPath);
275
+ expect(second).not.toBe(first);
276
+ });
277
+
278
+ it("stores pending activation bootstrap conversation and marks it sent conditionally", () => {
279
+ const store = createClawChatStore({ dbPath: tempDbPath() });
280
+ const bootstrapStore = store as typeof store & {
281
+ claimPendingActivationBootstrap(input: {
282
+ platform: string;
283
+ accountId: string;
284
+ }): { conversationId: string } | null;
285
+ markActivationBootstrapSent(input: {
286
+ platform: string;
287
+ accountId: string;
288
+ conversationId: string;
289
+ }): boolean | null;
290
+ releaseActivationBootstrapClaim(input: {
291
+ platform: string;
292
+ accountId: string;
293
+ conversationId: string;
294
+ }): boolean | null;
295
+ };
296
+
297
+ store.upsertActivation({
298
+ platform: "openclaw",
299
+ accountId: "default",
300
+ userId: "user-old",
301
+ accessToken: "token-old",
302
+ refreshToken: "refresh-old",
303
+ conversationId: "conv-old",
304
+ activatedAt: 1000,
305
+ loginMethod: "login",
306
+ });
307
+
308
+ expect(store.getActivationForTest("openclaw", "default")).toMatchObject({
309
+ conversation_id: "conv-old",
310
+ bootstrap_sent: 0,
311
+ });
312
+ expect(store.getActivationConversation({ platform: "openclaw", accountId: "default" })).toMatchObject({
313
+ conversationId: "conv-old",
314
+ conversationType: null,
315
+ });
316
+ expect(
317
+ bootstrapStore.claimPendingActivationBootstrap({ platform: "openclaw", accountId: "default" }),
318
+ ).toEqual({
319
+ conversationId: "conv-old",
320
+ });
321
+ expect(
322
+ bootstrapStore.claimPendingActivationBootstrap({ platform: "openclaw", accountId: "default" }),
323
+ ).toBeNull();
324
+ expect(
325
+ bootstrapStore.releaseActivationBootstrapClaim({
326
+ platform: "openclaw",
327
+ accountId: "default",
328
+ conversationId: "conv-other",
329
+ }),
330
+ ).toBe(false);
331
+ expect(
332
+ bootstrapStore.releaseActivationBootstrapClaim({
333
+ platform: "openclaw",
334
+ accountId: "default",
335
+ conversationId: "conv-old",
336
+ }),
337
+ ).toBe(true);
338
+ expect(
339
+ bootstrapStore.claimPendingActivationBootstrap({ platform: "openclaw", accountId: "default" }),
340
+ ).toEqual({
341
+ conversationId: "conv-old",
342
+ });
343
+ expect(
344
+ bootstrapStore.markActivationBootstrapSent({
345
+ platform: "openclaw",
346
+ accountId: "default",
347
+ conversationId: "conv-other",
348
+ }),
349
+ ).toBe(false);
350
+ expect(
351
+ bootstrapStore.markActivationBootstrapSent({
352
+ platform: "openclaw",
353
+ accountId: "default",
354
+ conversationId: "conv-old",
355
+ }),
356
+ ).toBe(true);
357
+ expect(
358
+ bootstrapStore.claimPendingActivationBootstrap({ platform: "openclaw", accountId: "default" }),
359
+ ).toBeNull();
360
+ expect(store.getActivationForTest("openclaw", "default")).toMatchObject({
361
+ conversation_id: "conv-old",
362
+ bootstrap_sent: 1,
363
+ });
364
+
365
+ store.upsertActivation({
366
+ platform: "openclaw",
367
+ accountId: "default",
368
+ userId: "user-new",
369
+ accessToken: "token-new",
370
+ refreshToken: null,
371
+ conversationId: "conv-new",
372
+ activatedAt: 2000,
373
+ loginMethod: "login",
374
+ });
375
+
376
+ expect(store.getActivationForTest("openclaw", "default")).toMatchObject({
377
+ conversation_id: "conv-new",
378
+ bootstrap_sent: 0,
379
+ updated_at: 2000,
380
+ });
381
+ expect(store.getActivationConversation({ platform: "openclaw", accountId: "default" })).toMatchObject({
382
+ conversationId: "conv-new",
383
+ });
384
+ expect(
385
+ bootstrapStore.claimPendingActivationBootstrap({ platform: "openclaw", accountId: "default" }),
386
+ ).toEqual({
387
+ conversationId: "conv-new",
388
+ });
389
+
390
+ store.close();
391
+ });
392
+
393
+ it("reclaims stale activation bootstrap claims after the timeout", () => {
394
+ vi.useFakeTimers();
395
+ vi.setSystemTime(10_000);
396
+ const store = createClawChatStore({ dbPath: tempDbPath() });
397
+ const bootstrapStore = store as typeof store & {
398
+ claimPendingActivationBootstrap(input: {
399
+ platform: string;
400
+ accountId: string;
401
+ staleClaimMs?: number;
402
+ }): { conversationId: string } | null;
403
+ };
404
+
405
+ store.upsertActivation({
406
+ platform: "openclaw",
407
+ accountId: "default",
408
+ userId: "user-new",
409
+ conversationId: "conv-stale",
410
+ activatedAt: 10_000,
411
+ loginMethod: "login",
412
+ });
413
+
414
+ expect(
415
+ bootstrapStore.claimPendingActivationBootstrap({
416
+ platform: "openclaw",
417
+ accountId: "default",
418
+ staleClaimMs: 1000,
419
+ }),
420
+ ).toEqual({ conversationId: "conv-stale" });
421
+ expect(
422
+ bootstrapStore.claimPendingActivationBootstrap({
423
+ platform: "openclaw",
424
+ accountId: "default",
425
+ staleClaimMs: 1000,
426
+ }),
427
+ ).toBeNull();
428
+
429
+ vi.setSystemTime(11_001);
430
+ expect(
431
+ bootstrapStore.claimPendingActivationBootstrap({
432
+ platform: "openclaw",
433
+ accountId: "default",
434
+ staleClaimMs: 1000,
435
+ }),
436
+ ).toEqual({ conversationId: "conv-stale" });
437
+
438
+ store.close();
439
+ });
440
+
441
+ it("upserts message-path conversation summaries without members or profiles", () => {
442
+ const store = createClawChatStore({ dbPath: tempDbPath() });
443
+
444
+ store.upsertConversationSummary({
445
+ platform: "openclaw",
446
+ accountId: "default",
447
+ conversationId: "conv-summary",
448
+ conversationType: "direct",
449
+ metadataVersion: 3,
450
+ lastSeenAt: 3000,
451
+ raw: { source: "message" },
452
+ });
453
+
454
+ expect(store.listCachedConversationIds({ platform: "openclaw", accountId: "default", limit: 10 })).toEqual([
455
+ "conv-summary",
456
+ ]);
457
+ expect(store.listConversationCacheForTest("clawchat_conversations")).toMatchObject([
458
+ {
459
+ conversation_id: "conv-summary",
460
+ conversation_type: "direct",
461
+ metadata_version: 3,
462
+ last_seen_at: 3000,
463
+ },
464
+ ]);
465
+ expect(store.listConversationCacheForTest("clawchat_conversation_members")).toEqual([]);
466
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toEqual([]);
467
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toEqual([]);
468
+
469
+ store.close();
470
+ });
471
+
472
+ it("upserts full conversation details and replaces members only for complete snapshots", () => {
473
+ const store = createClawChatStore({ dbPath: tempDbPath() });
474
+
475
+ store.upsertConversationDetails({
476
+ platform: "openclaw",
477
+ accountId: "default",
478
+ conversationId: "group-1",
479
+ conversationType: "group",
480
+ metadataVersion: 7,
481
+ lastSeenAt: 5000,
482
+ lastRefreshedAt: 5100,
483
+ raw: { id: "group-1" },
484
+ groupProfile: {
485
+ title: "Launch room",
486
+ description: "Planning",
487
+ metadataVersion: 7,
488
+ raw: { title: "Launch room" },
489
+ lastRefreshedAt: 5100,
490
+ },
491
+ userProfiles: [
492
+ { userId: "user-a", nickname: "Ada", avatarUrl: "https://example.test/a.png", bio: "A", raw: { id: "a" } },
493
+ { userId: "user-b", nickname: "Ben", raw: { id: "b" } },
494
+ ],
495
+ members: [
496
+ { userId: "user-a", role: "owner", raw: { role: "owner" }, lastSeenAt: 5001 },
497
+ { userId: "user-b", role: "member", raw: { role: "member" }, lastSeenAt: 5002 },
498
+ ],
499
+ membersComplete: true,
500
+ });
501
+ store.upsertConversationDetails({
502
+ platform: "openclaw",
503
+ accountId: "default",
504
+ conversationId: "group-1",
505
+ conversationType: "group",
506
+ lastSeenAt: 6000,
507
+ members: [{ userId: "user-c", role: "guest" }],
508
+ membersComplete: false,
509
+ });
510
+
511
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toMatchObject([
512
+ { conversation_id: "group-1", title: "Launch room", description: "Planning", metadata_version: 7 },
513
+ ]);
514
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toMatchObject([
515
+ { user_id: "user-a", nickname: "Ada", avatar_url: "https://example.test/a.png", bio: "A" },
516
+ { user_id: "user-b", nickname: "Ben" },
517
+ ]);
518
+ expect(store.listConversationCacheForTest("clawchat_conversation_members")).toMatchObject([
519
+ { conversation_id: "group-1", user_id: "user-a", role: "owner" },
520
+ { conversation_id: "group-1", user_id: "user-b", role: "member" },
521
+ ]);
522
+
523
+ store.upsertConversationDetails({
524
+ platform: "openclaw",
525
+ accountId: "default",
526
+ conversationId: "group-1",
527
+ members: [{ userId: "user-c", role: "guest" }],
528
+ membersComplete: true,
529
+ });
530
+
531
+ expect(store.listConversationCacheForTest("clawchat_conversation_members")).toMatchObject([
532
+ { conversation_id: "group-1", user_id: "user-c", role: "guest" },
533
+ ]);
534
+
535
+ store.close();
536
+ });
537
+
538
+ it("preserves omitted cache fields and clears explicit null cache fields", () => {
539
+ const store = createClawChatStore({ dbPath: tempDbPath() });
540
+
541
+ store.upsertConversationDetails({
542
+ platform: "openclaw",
543
+ accountId: "default",
544
+ conversationId: "cache-clear",
545
+ conversationType: "group",
546
+ metadataVersion: 4,
547
+ raw: { old: "conversation" },
548
+ groupProfile: {
549
+ title: "Old title",
550
+ description: "Old description",
551
+ metadataVersion: 5,
552
+ raw: { old: "group" },
553
+ },
554
+ userProfiles: [
555
+ {
556
+ userId: "user-clear",
557
+ nickname: "Old nick",
558
+ avatarUrl: "https://example.test/old.png",
559
+ bio: "Old bio",
560
+ raw: { old: "user" },
561
+ },
562
+ ],
563
+ });
564
+
565
+ store.upsertConversationDetails({
566
+ platform: "openclaw",
567
+ accountId: "default",
568
+ conversationId: "cache-clear",
569
+ groupProfile: {},
570
+ userProfiles: [{ userId: "user-clear" }],
571
+ });
572
+
573
+ expect(store.listConversationCacheForTest("clawchat_conversations")).toMatchObject([
574
+ { conversation_type: "group", metadata_version: 4, raw_json: JSON.stringify({ old: "conversation" }) },
575
+ ]);
576
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toMatchObject([
577
+ {
578
+ title: "Old title",
579
+ description: "Old description",
580
+ metadata_version: 5,
581
+ raw_json: JSON.stringify({ old: "group" }),
582
+ },
583
+ ]);
584
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toMatchObject([
585
+ {
586
+ nickname: "Old nick",
587
+ avatar_url: "https://example.test/old.png",
588
+ bio: "Old bio",
589
+ raw_json: JSON.stringify({ old: "user" }),
590
+ },
591
+ ]);
592
+
593
+ store.upsertConversationDetails({
594
+ platform: "openclaw",
595
+ accountId: "default",
596
+ conversationId: "cache-clear",
597
+ conversationType: null,
598
+ metadataVersion: null,
599
+ raw: null,
600
+ groupProfile: {
601
+ title: null,
602
+ description: null,
603
+ metadataVersion: null,
604
+ raw: null,
605
+ },
606
+ userProfiles: [
607
+ {
608
+ userId: "user-clear",
609
+ nickname: null,
610
+ avatarUrl: null,
611
+ bio: null,
612
+ raw: null,
613
+ },
614
+ ],
615
+ });
616
+
617
+ expect(store.listConversationCacheForTest("clawchat_conversations")).toMatchObject([
618
+ { conversation_type: null, metadata_version: null, raw_json: null },
619
+ ]);
620
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toMatchObject([
621
+ { title: null, description: null, metadata_version: null, raw_json: null },
622
+ ]);
623
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toMatchObject([
624
+ { nickname: null, avatar_url: null, bio: null, raw_json: null },
625
+ ]);
626
+
627
+ store.close();
628
+ });
629
+
630
+ it("preserves omitted cache timestamps and clears explicit null cache timestamps", () => {
631
+ const store = createClawChatStore({ dbPath: tempDbPath() });
632
+
633
+ store.upsertConversationDetails({
634
+ platform: "openclaw",
635
+ accountId: "default",
636
+ conversationId: "cache-timestamps",
637
+ lastSeenAt: 1000,
638
+ lastRefreshedAt: 1100,
639
+ groupProfile: {
640
+ title: "Timestamp room",
641
+ lastRefreshedAt: 1200,
642
+ },
643
+ userProfiles: [
644
+ {
645
+ userId: "user-timestamps",
646
+ nickname: "Timey",
647
+ lastRefreshedAt: 1300,
648
+ },
649
+ ],
650
+ });
651
+
652
+ store.upsertConversationDetails({
653
+ platform: "openclaw",
654
+ accountId: "default",
655
+ conversationId: "cache-timestamps",
656
+ groupProfile: {},
657
+ userProfiles: [{ userId: "user-timestamps" }],
658
+ });
659
+
660
+ expect(store.listConversationCacheForTest("clawchat_conversations")).toMatchObject([
661
+ { last_seen_at: 1000, last_refreshed_at: 1100 },
662
+ ]);
663
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toMatchObject([
664
+ { last_refreshed_at: 1200 },
665
+ ]);
666
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toMatchObject([
667
+ { last_refreshed_at: 1300 },
668
+ ]);
669
+
670
+ store.upsertConversationDetails({
671
+ platform: "openclaw",
672
+ accountId: "default",
673
+ conversationId: "cache-timestamps",
674
+ lastSeenAt: null,
675
+ lastRefreshedAt: null,
676
+ groupProfile: { lastRefreshedAt: null },
677
+ userProfiles: [{ userId: "user-timestamps", lastRefreshedAt: null }],
678
+ });
679
+
680
+ expect(store.listConversationCacheForTest("clawchat_conversations")).toMatchObject([
681
+ { last_seen_at: null, last_refreshed_at: null },
682
+ ]);
683
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toMatchObject([
684
+ { last_refreshed_at: null },
685
+ ]);
686
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toMatchObject([
687
+ { last_refreshed_at: null },
688
+ ]);
689
+
690
+ store.close();
691
+ });
692
+
693
+ it("deletes conversation cache without deleting user profiles or messages", () => {
694
+ const store = createClawChatStore({ dbPath: tempDbPath() });
695
+
696
+ store.upsertConversationDetails({
697
+ platform: "openclaw",
698
+ accountId: "default",
699
+ conversationId: "group-delete",
700
+ conversationType: "group",
701
+ groupProfile: { title: "Delete me" },
702
+ userProfiles: [{ userId: "user-keep", nickname: "Kee" }],
703
+ members: [{ userId: "user-keep", role: "member" }],
704
+ membersComplete: true,
705
+ });
706
+ store.insertMessage({
707
+ platform: "openclaw",
708
+ accountId: "default",
709
+ kind: "message",
710
+ direction: "inbound",
711
+ eventType: "message.send",
712
+ chatId: "group-delete",
713
+ messageId: "msg-keep",
714
+ text: "keep me",
715
+ createdAt: 7000,
716
+ });
717
+
718
+ store.deleteConversationCache({ platform: "openclaw", accountId: "default", conversationId: "group-delete" });
719
+
720
+ expect(store.listConversationCacheForTest("clawchat_conversations")).toEqual([]);
721
+ expect(store.listConversationCacheForTest("clawchat_group_profiles")).toEqual([]);
722
+ expect(store.listConversationCacheForTest("clawchat_conversation_members")).toEqual([]);
723
+ expect(store.listConversationCacheForTest("clawchat_user_profiles")).toMatchObject([
724
+ { user_id: "user-keep", nickname: "Kee" },
725
+ ]);
726
+ expect(store.listMessagesForTest()).toMatchObject([{ chat_id: "group-delete", message_id: "msg-keep" }]);
727
+
728
+ store.close();
729
+ });
730
+
731
+ it("lists cached conversation ids by latest seen time with a limit", () => {
732
+ const store = createClawChatStore({ dbPath: tempDbPath() });
733
+
734
+ store.upsertConversationSummary({
735
+ platform: "openclaw",
736
+ accountId: "default",
737
+ conversationId: "old",
738
+ lastSeenAt: 1000,
739
+ });
740
+ store.upsertConversationSummary({
741
+ platform: "openclaw",
742
+ accountId: "default",
743
+ conversationId: "new",
744
+ lastSeenAt: 3000,
745
+ });
746
+ store.upsertConversationSummary({
747
+ platform: "openclaw",
748
+ accountId: "default",
749
+ conversationId: "middle",
750
+ lastSeenAt: 2000,
751
+ });
752
+
753
+ expect(store.listCachedConversationIds({ platform: "openclaw", accountId: "default", limit: 2 })).toEqual([
754
+ "new",
755
+ "middle",
756
+ ]);
757
+
758
+ store.close();
759
+ });
760
+
761
+ it("gets one cached conversation metadata row by id", () => {
762
+ const store = createClawChatStore({ dbPath: tempDbPath() });
763
+
764
+ store.upsertConversationSummary({
765
+ platform: "openclaw",
766
+ accountId: "default",
767
+ conversationId: "group-meta",
768
+ conversationType: "group",
769
+ metadataVersion: 9,
770
+ lastSeenAt: 4000,
771
+ lastRefreshedAt: 4100,
772
+ });
773
+
774
+ expect(store.getCachedConversation({
775
+ platform: "openclaw",
776
+ accountId: "default",
777
+ conversationId: "group-meta",
778
+ })).toMatchObject({
779
+ conversationId: "group-meta",
780
+ conversationType: "group",
781
+ metadataVersion: 9,
782
+ lastSeenAt: 4000,
783
+ lastRefreshedAt: 4100,
784
+ });
785
+ expect(store.getCachedConversation({
786
+ platform: "openclaw",
787
+ accountId: "default",
788
+ conversationId: "missing",
789
+ })).toBeNull();
790
+
791
+ store.close();
792
+ });
793
+ });