@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21

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 (99) hide show
  1. package/README.md +39 -17
  2. package/dist/index.js +3 -1
  3. package/dist/src/api-client.js +71 -12
  4. package/dist/src/api-types.test-d.js +10 -0
  5. package/dist/src/channel.js +5 -5
  6. package/dist/src/channel.setup.js +4 -17
  7. package/dist/src/clawchat-memory.js +290 -0
  8. package/dist/src/clawchat-metadata.js +235 -0
  9. package/dist/src/client.js +31 -93
  10. package/dist/src/commands.js +3 -3
  11. package/dist/src/config.js +58 -3
  12. package/dist/src/group-message-coalescer.js +107 -0
  13. package/dist/src/inbound.js +24 -28
  14. package/dist/src/login.runtime.js +82 -19
  15. package/dist/src/media-runtime.js +2 -3
  16. package/dist/src/message-mapper.js +1 -1
  17. package/dist/src/mock-transport.js +31 -0
  18. package/dist/src/outbound.js +281 -56
  19. package/dist/src/plugin-prompts.js +76 -0
  20. package/dist/src/profile-prompt.js +150 -0
  21. package/dist/src/profile-sync.js +169 -0
  22. package/dist/src/prompt-injection.js +25 -0
  23. package/dist/src/protocol-types.js +63 -0
  24. package/dist/src/protocol-types.typecheck.js +1 -0
  25. package/dist/src/protocol.js +2 -2
  26. package/dist/src/reply-dispatcher.js +143 -40
  27. package/dist/src/runtime.js +813 -109
  28. package/dist/src/storage.js +636 -0
  29. package/dist/src/tools-schema.js +70 -10
  30. package/dist/src/tools.js +600 -112
  31. package/dist/src/ws-alignment.js +8 -0
  32. package/dist/src/ws-client.js +588 -0
  33. package/index.ts +6 -1
  34. package/openclaw.plugin.json +44 -4
  35. package/package.json +4 -3
  36. package/prompts/platform.md +7 -0
  37. package/skills/clawchat/SKILL.md +90 -0
  38. package/src/api-client.test.ts +360 -15
  39. package/src/api-client.ts +127 -25
  40. package/src/api-types.test-d.ts +12 -0
  41. package/src/api-types.ts +71 -4
  42. package/src/buffered-stream.test.ts +1 -1
  43. package/src/buffered-stream.ts +1 -1
  44. package/src/channel.outbound.test.ts +270 -60
  45. package/src/channel.setup.ts +9 -18
  46. package/src/channel.test.ts +33 -25
  47. package/src/channel.ts +5 -7
  48. package/src/clawchat-memory.test.ts +372 -0
  49. package/src/clawchat-memory.ts +363 -0
  50. package/src/clawchat-metadata.test.ts +350 -0
  51. package/src/clawchat-metadata.ts +352 -0
  52. package/src/client.test.ts +57 -48
  53. package/src/client.ts +37 -129
  54. package/src/commands.test.ts +2 -2
  55. package/src/commands.ts +3 -3
  56. package/src/config.test.ts +169 -4
  57. package/src/config.ts +86 -6
  58. package/src/group-message-coalescer.test.ts +223 -0
  59. package/src/group-message-coalescer.ts +154 -0
  60. package/src/inbound.test.ts +106 -19
  61. package/src/inbound.ts +31 -35
  62. package/src/login.runtime.test.ts +294 -11
  63. package/src/login.runtime.ts +90 -21
  64. package/src/manifest.test.ts +86 -14
  65. package/src/media-runtime.test.ts +31 -2
  66. package/src/media-runtime.ts +7 -10
  67. package/src/message-mapper.test.ts +2 -2
  68. package/src/message-mapper.ts +2 -2
  69. package/src/mock-transport.test.ts +35 -0
  70. package/src/mock-transport.ts +38 -0
  71. package/src/outbound.test.ts +811 -95
  72. package/src/outbound.ts +332 -65
  73. package/src/plugin-entry.test.ts +3 -1
  74. package/src/plugin-prompts.test.ts +78 -0
  75. package/src/plugin-prompts.ts +92 -0
  76. package/src/profile-prompt.test.ts +435 -0
  77. package/src/profile-prompt.ts +208 -0
  78. package/src/profile-sync.test.ts +611 -0
  79. package/src/profile-sync.ts +268 -0
  80. package/src/prompt-injection.test.ts +39 -0
  81. package/src/prompt-injection.ts +45 -0
  82. package/src/protocol-types.test.ts +69 -0
  83. package/src/protocol-types.ts +296 -0
  84. package/src/protocol-types.typecheck.ts +89 -0
  85. package/src/protocol.ts +2 -2
  86. package/src/reply-dispatcher.test.ts +720 -135
  87. package/src/reply-dispatcher.ts +174 -42
  88. package/src/runtime.test.ts +3884 -337
  89. package/src/runtime.ts +956 -128
  90. package/src/storage.test.ts +692 -0
  91. package/src/storage.ts +989 -0
  92. package/src/streaming.test.ts +1 -1
  93. package/src/streaming.ts +1 -1
  94. package/src/tools-schema.ts +115 -13
  95. package/src/tools.test.ts +501 -10
  96. package/src/tools.ts +739 -133
  97. package/src/ws-alignment.ts +9 -0
  98. package/src/ws-client.test.ts +1218 -0
  99. package/src/ws-client.ts +662 -0
@@ -1,9 +1,14 @@
1
- import { MockTransport, AuthError } from "@newbase-clawchat/sdk";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
3
- import { describe, expect, it, vi } from "vitest";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { MockTransport } from "./mock-transport.ts";
7
+ import { AuthError } from "./protocol-types.ts";
4
8
  import {
5
9
  classifyClawlingClientError,
6
10
  mapClawlingStateToStatus,
11
+ resolveClawChatMemoryRoot,
7
12
  setOpenclawClawlingRuntime,
8
13
  getOpenclawClawlingRuntime,
9
14
  startOpenclawClawlingGateway,
@@ -11,6 +16,13 @@ import {
11
16
  } from "./runtime.ts";
12
17
  import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
13
18
  import { sendOpenclawClawlingText } from "./outbound.ts";
19
+ import {
20
+ clearClawChatPromptInjections,
21
+ registerClawChatPromptInjection,
22
+ renderClawChatPromptInjectionForSession,
23
+ stageClawChatPromptInjection,
24
+ } from "./prompt-injection.ts";
25
+ import { readClawChatMemoryFile, writeClawChatMetadata } from "./clawchat-memory.ts";
14
26
 
15
27
  function baseAccount(
16
28
  overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
@@ -23,10 +35,16 @@ function baseAccount(
23
35
  websocketUrl: "ws://t",
24
36
  baseUrl: "https://api.example.com",
25
37
  token: "tk",
38
+ agentId: "agt-1",
26
39
  userId: "u",
40
+ ownerUserId: "owner-u",
27
41
  replyMode: "static",
42
+ groupMode: "all",
43
+ groupCommandMode: "owner",
44
+ groups: {},
28
45
  forwardThinking: true,
29
46
  forwardToolCalls: false,
47
+ richInteractions: false,
30
48
  allowFrom: [],
31
49
  stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
32
50
  reconnect: {
@@ -41,8 +59,729 @@ function baseAccount(
41
59
  };
42
60
  }
43
61
 
62
+ const EXPECTED_ACTIVATION_BOOTSTRAP_TEXT = [
63
+ "ClawChat activation bootstrap: You are now connected to this ClawChat direct conversation.",
64
+ "Please do both:",
65
+ "1. Send a brief, friendly greeting to the user in this ClawChat direct conversation.",
66
+ "2. If you have local profile information for yourself, such as display name, bio, or avatar, update the connected ClawChat account profile using the available ClawChat tools. Use `clawchat_update_account_profile` for display name/bio/avatar URL, and use `clawchat_upload_avatar_image` first if the avatar is only available as a local image path. If you do not have local profile information, skip profile updates and only greet the user.",
67
+ "Do not ask the user for profile information just for this bootstrap.",
68
+ ].join("\n");
69
+
70
+ beforeEach(() => {
71
+ clearClawChatPromptInjections();
72
+ });
73
+
74
+ function buildTestInboundContext(params: {
75
+ channel: string;
76
+ accountId?: string;
77
+ provider?: string;
78
+ surface?: string;
79
+ messageId?: string;
80
+ messageIdFull?: string;
81
+ timestamp?: number;
82
+ from: string;
83
+ sender: { id: string; name?: string; displayLabel?: string };
84
+ conversation: { kind: "direct" | "group" | "channel"; label?: string };
85
+ route: { accountId?: string; routeSessionKey: string; dispatchSessionKey?: string };
86
+ reply: { to: string; originatingTo: string };
87
+ message: { body?: string; rawBody: string; bodyForAgent?: string; commandBody?: string };
88
+ access?: { mentions?: { wasMentioned?: boolean; mentionedUserIds?: string[] } };
89
+ supplemental?: { groupSystemPrompt?: string };
90
+ }) {
91
+ return {
92
+ Body: params.message.body ?? params.message.rawBody,
93
+ BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
94
+ RawBody: params.message.rawBody,
95
+ CommandBody: params.message.commandBody ?? params.message.rawBody,
96
+ From: params.from,
97
+ To: params.reply.to,
98
+ SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
99
+ AccountId: params.route.accountId ?? params.accountId,
100
+ MessageSid: params.messageId,
101
+ MessageSidFull: params.messageIdFull,
102
+ ChatType: params.conversation.kind,
103
+ ConversationLabel: params.conversation.label,
104
+ GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
105
+ GroupSystemPrompt: params.supplemental?.groupSystemPrompt,
106
+ SenderName: params.sender.name ?? params.sender.displayLabel,
107
+ SenderId: params.sender.id,
108
+ Timestamp: params.timestamp,
109
+ Provider: params.provider ?? params.channel,
110
+ Surface: params.surface ?? params.provider ?? params.channel,
111
+ WasMentioned: params.access?.mentions?.wasMentioned,
112
+ MentionedUserIds: params.access?.mentions?.mentionedUserIds,
113
+ OriginatingChannel: params.channel,
114
+ OriginatingTo: params.reply.originatingTo,
115
+ };
116
+ }
117
+
118
+ async function completeHandshake(
119
+ transport: MockTransport,
120
+ challengeTraceId = "challenge-bootstrap",
121
+ helloPayload: Record<string, unknown> = {},
122
+ ): Promise<Record<string, unknown>> {
123
+ await Promise.resolve();
124
+ transport.emitInbound(
125
+ JSON.stringify({
126
+ version: "2",
127
+ event: "connect.challenge",
128
+ trace_id: challengeTraceId,
129
+ emitted_at: Date.now(),
130
+ payload: { nonce: `${challengeTraceId}-nonce` },
131
+ }),
132
+ );
133
+ const connectFrame = transport.sent
134
+ .map((raw) => JSON.parse(raw) as Record<string, unknown>)
135
+ .filter((env) => env.event === "connect")
136
+ .at(-1)!;
137
+ transport.emitInbound(
138
+ JSON.stringify({
139
+ version: "2",
140
+ event: "hello-ok",
141
+ trace_id: connectFrame.trace_id,
142
+ emitted_at: Date.now(),
143
+ payload: helloPayload,
144
+ }),
145
+ );
146
+ await Promise.resolve();
147
+ return connectFrame;
148
+ }
149
+
150
+ function jsonEnvelope(data: unknown, status = 200): Response {
151
+ return new Response(JSON.stringify(data), {
152
+ status,
153
+ headers: { "content-type": "application/json" },
154
+ });
155
+ }
156
+
157
+ function conversationDetails(id: string, overrides: Record<string, unknown> = {}) {
158
+ return {
159
+ id,
160
+ type: "group",
161
+ title: `Room ${id}`,
162
+ description: `Description ${id}`,
163
+ creator_id: "user-owner",
164
+ created_at: "2026-05-21T10:00:00.000Z",
165
+ updated_at: "2026-05-21T10:01:00.000Z",
166
+ participants: [
167
+ {
168
+ conversation_id: id,
169
+ user_id: "user-owner",
170
+ role: "owner",
171
+ joined_at: "2026-05-21T10:00:30.000Z",
172
+ nickname: "Owner",
173
+ avatar_url: "https://cdn.example/owner.png",
174
+ },
175
+ ],
176
+ ...overrides,
177
+ };
178
+ }
179
+
180
+ function tempMemoryRoot(): string {
181
+ return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-clawchat-runtime-"));
182
+ }
183
+
184
+ function createTestMemoryAgent(memoryRoot = tempMemoryRoot()) {
185
+ return {
186
+ resolveAgentWorkspaceDir: vi.fn(() => memoryRoot),
187
+ };
188
+ }
189
+
190
+ function buildNoDispatchRuntime(
191
+ dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined),
192
+ memoryRoot = tempMemoryRoot(),
193
+ ) {
194
+ return {
195
+ agent: createTestMemoryAgent(memoryRoot),
196
+ channel: {
197
+ routing: {
198
+ resolveAgentRoute: vi.fn(() => ({
199
+ agentId: "default",
200
+ accountId: "default",
201
+ sessionKey: "session-from-route",
202
+ })),
203
+ },
204
+ session: {
205
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
206
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
207
+ },
208
+ reply: {
209
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
210
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
211
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
212
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
213
+ createReplyDispatcherWithTyping: vi.fn(() => ({
214
+ dispatcher: {},
215
+ replyOptions: {},
216
+ markDispatchIdle: vi.fn(),
217
+ markRunComplete: vi.fn(),
218
+ })),
219
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
220
+ dispatchReplyFromConfig,
221
+ },
222
+ turn: {
223
+ buildContext: vi.fn((params) =>
224
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
225
+ ),
226
+ },
227
+ media: {
228
+ fetchRemoteMedia: vi.fn(),
229
+ saveMediaBuffer: vi.fn(),
230
+ loadWebMedia: vi.fn(),
231
+ },
232
+ },
233
+ } as unknown as PluginRuntime;
234
+ }
235
+
236
+ function inboundMessageEnvelope(params: {
237
+ chatId: string;
238
+ chatType: "direct" | "group";
239
+ messageId: string;
240
+ senderId: string;
241
+ text: string;
242
+ traceId?: string;
243
+ mentions?: unknown[];
244
+ emittedAt?: number;
245
+ senderType?: "agent" | "user" | "direct";
246
+ }) {
247
+ return {
248
+ version: "2",
249
+ event: "message.send",
250
+ trace_id: params.traceId ?? `trace-${params.messageId}`,
251
+ emitted_at: params.emittedAt ?? Date.now(),
252
+ chat_id: params.chatId,
253
+ chat_type: params.chatType,
254
+ to: { id: "u", type: params.chatType },
255
+ sender: {
256
+ id: params.senderId,
257
+ type: params.senderType ?? "direct",
258
+ nick_name: params.senderId.replace(/^u(\d+)$/, "user-$1"),
259
+ },
260
+ payload: {
261
+ message_id: params.messageId,
262
+ message_mode: "normal",
263
+ message: {
264
+ body: { fragments: [{ kind: "text", text: params.text }] },
265
+ context: { mentions: params.mentions ?? [], reply: null },
266
+ streaming: {
267
+ status: "static",
268
+ sequence: 0,
269
+ mutation_policy: "sealed",
270
+ started_at: null,
271
+ completed_at: null,
272
+ },
273
+ },
274
+ },
275
+ };
276
+ }
277
+
278
+ function mockMetadataFetches() {
279
+ return vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
280
+ const url = String(input);
281
+ const userMatch = url.match(/\/v1\/users\/([^/?#]+)$/);
282
+ if (userMatch) {
283
+ const userId = decodeURIComponent(userMatch[1]!);
284
+ return jsonEnvelope({
285
+ code: 0,
286
+ msg: "ok",
287
+ data: {
288
+ id: userId,
289
+ type: userId.includes("agent") ? "agent" : "user",
290
+ nickname: `User ${userId}`,
291
+ avatar_url: `https://cdn.example/${userId}.png`,
292
+ bio: `Bio ${userId}`,
293
+ updated_at: "2026-05-24T01:00:00.000Z",
294
+ },
295
+ });
296
+ }
297
+ const conversationMatch = url.match(/\/v1\/conversations\/([^/?#]+)$/);
298
+ if (conversationMatch) {
299
+ const groupId = decodeURIComponent(conversationMatch[1]!);
300
+ return jsonEnvelope({
301
+ code: 0,
302
+ msg: "ok",
303
+ data: {
304
+ conversation: conversationDetails(groupId, {
305
+ participants: [
306
+ {
307
+ conversation_id: groupId,
308
+ user_id: "participant-1",
309
+ role: "member",
310
+ joined_at: "2026-05-24T01:01:00.000Z",
311
+ },
312
+ {
313
+ conversation_id: groupId,
314
+ user_id: "participant-agent",
315
+ role: "member",
316
+ joined_at: "2026-05-24T01:02:00.000Z",
317
+ },
318
+ ],
319
+ }),
320
+ },
321
+ });
322
+ }
323
+ const agentMatch = url.match(/\/v1\/agents\/([^/?#]+)$/);
324
+ if (agentMatch) {
325
+ const agentId = decodeURIComponent(agentMatch[1]!);
326
+ return jsonEnvelope({
327
+ code: 0,
328
+ msg: "ok",
329
+ data: {
330
+ agent: {
331
+ id: agentId,
332
+ user_id: "u",
333
+ owner_id: "owner-u",
334
+ nickname: "Hermes",
335
+ avatar_url: "https://cdn.example/hermes.png",
336
+ bio: "Agent bio",
337
+ behavior: "Use current owner metadata.",
338
+ updated_at: "2026-05-24T01:03:00.000Z",
339
+ },
340
+ },
341
+ });
342
+ }
343
+ return new Response("unexpected test URL", { status: 500 });
344
+ });
345
+ }
346
+
347
+ describe("openclaw-clawchat runtime memory metadata refresh", () => {
348
+ it("activation success pulls owner metadata into owner.md", async () => {
349
+ const memoryRoot = tempMemoryRoot();
350
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
351
+ const fetchMock = mockMetadataFetches();
352
+ let bootstrapClaimed = false;
353
+ const store = {
354
+ startConnection: vi.fn(() => 501),
355
+ markConnectSent: vi.fn(),
356
+ markConnectionReady: vi.fn(),
357
+ finishConnection: vi.fn(),
358
+ claimPendingActivationBootstrap: vi.fn(() => {
359
+ if (bootstrapClaimed) return null;
360
+ bootstrapClaimed = true;
361
+ return { conversationId: "dm-activation" };
362
+ }),
363
+ releaseActivationBootstrapClaim: vi.fn(),
364
+ markActivationBootstrapSent: vi.fn(),
365
+ claimMessageOnce: vi.fn(() => true),
366
+ };
367
+ const transport = new MockTransport();
368
+ const abortController = new AbortController();
369
+
370
+ try {
371
+ setOpenclawClawlingRuntime(runtime);
372
+ const run = startOpenclawClawlingGateway({
373
+ cfg: {},
374
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
375
+ abortSignal: abortController.signal,
376
+ setStatus: vi.fn(),
377
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
378
+ log: { info: vi.fn(), error: vi.fn() },
379
+ transport,
380
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
381
+ });
382
+
383
+ await completeHandshake(transport, "challenge-activation-owner-metadata");
384
+ await new Promise((resolve) => setTimeout(resolve, 50));
385
+
386
+ const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
387
+ expect(ownerFile.metadata).toMatchObject({
388
+ agent_id: "u",
389
+ owner_id: "owner-u",
390
+ nickname: "Hermes",
391
+ behavior: "Use current owner metadata.",
392
+ });
393
+ expect(fetchMock).toHaveBeenCalledWith(
394
+ "https://api.example.com/v1/agents/agt-1",
395
+ expect.objectContaining({ method: "GET" }),
396
+ );
397
+
398
+ abortController.abort();
399
+ await run;
400
+ } finally {
401
+ fetchMock.mockRestore();
402
+ }
403
+ });
404
+
405
+ it("ordinary direct message pulls sender user metadata", async () => {
406
+ const memoryRoot = tempMemoryRoot();
407
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
408
+ const fetchMock = mockMetadataFetches();
409
+ const store = {
410
+ startConnection: vi.fn(() => 502),
411
+ markConnectSent: vi.fn(),
412
+ markConnectionReady: vi.fn(),
413
+ finishConnection: vi.fn(),
414
+ getCachedConversation: vi.fn(() => null),
415
+ upsertConversationSummary: vi.fn(),
416
+ claimMessageOnce: vi.fn(() => true),
417
+ };
418
+ const transport = new MockTransport();
419
+ const abortController = new AbortController();
420
+
421
+ try {
422
+ setOpenclawClawlingRuntime(runtime);
423
+ const run = startOpenclawClawlingGateway({
424
+ cfg: {},
425
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
426
+ abortSignal: abortController.signal,
427
+ setStatus: vi.fn(),
428
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
429
+ log: { info: vi.fn(), error: vi.fn() },
430
+ transport,
431
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
432
+ });
433
+
434
+ await completeHandshake(transport, "challenge-direct-user-metadata");
435
+ transport.emitInbound(JSON.stringify(inboundMessageEnvelope({
436
+ chatId: "dm-user",
437
+ chatType: "direct",
438
+ messageId: "msg-user-metadata",
439
+ senderId: "user-1",
440
+ text: "hello",
441
+ })));
442
+ await new Promise((resolve) => setTimeout(resolve, 50));
443
+
444
+ const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: "user-1" });
445
+ expect(userFile.metadata).toMatchObject({
446
+ id: "user-1",
447
+ nickname: "User user-1",
448
+ avatar_url: "https://cdn.example/user-1.png",
449
+ bio: "Bio user-1",
450
+ profile_type: "user",
451
+ });
452
+ expect(fetchMock).toHaveBeenCalledWith(
453
+ "https://api.example.com/v1/users/user-1",
454
+ expect.objectContaining({ method: "GET" }),
455
+ );
456
+
457
+ abortController.abort();
458
+ await run;
459
+ } finally {
460
+ fetchMock.mockRestore();
461
+ }
462
+ });
463
+
464
+ it("owner direct message does not inject users owner metadata", async () => {
465
+ const memoryRoot = tempMemoryRoot();
466
+ await writeClawChatMetadata(memoryRoot, { targetType: "owner", targetId: "owner" }, {
467
+ agent_id: "u",
468
+ owner_id: "owner-u",
469
+ nickname: "Hermes",
470
+ behavior: "Use owner metadata.",
471
+ });
472
+ const handlers = new Map<string, Function>();
473
+ registerClawChatPromptInjection({
474
+ on: vi.fn((name: string, handler: Function) => handlers.set(name, handler)),
475
+ });
476
+ let promptBuildResult: unknown;
477
+ const dispatchReplyFromConfig = vi.fn(async () => {
478
+ promptBuildResult = await handlers.get("before_prompt_build")?.({}, { sessionKey: "session-from-route" });
479
+ });
480
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig, memoryRoot);
481
+ const fetchMock = mockMetadataFetches();
482
+ const store = {
483
+ startConnection: vi.fn(() => 503),
484
+ markConnectSent: vi.fn(),
485
+ markConnectionReady: vi.fn(),
486
+ finishConnection: vi.fn(),
487
+ getCachedConversation: vi.fn(() => ({ conversationId: "dm-owner" })),
488
+ claimMessageOnce: vi.fn(() => true),
489
+ };
490
+ const transport = new MockTransport();
491
+ const abortController = new AbortController();
492
+
493
+ try {
494
+ setOpenclawClawlingRuntime(runtime);
495
+ const run = startOpenclawClawlingGateway({
496
+ cfg: {},
497
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
498
+ abortSignal: abortController.signal,
499
+ setStatus: vi.fn(),
500
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
501
+ log: { info: vi.fn(), error: vi.fn() },
502
+ transport,
503
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
504
+ });
505
+
506
+ await completeHandshake(transport, "challenge-owner-direct-metadata");
507
+ transport.emitInbound(JSON.stringify(inboundMessageEnvelope({
508
+ chatId: "dm-owner",
509
+ chatType: "direct",
510
+ messageId: "msg-owner-metadata",
511
+ senderId: "owner-u",
512
+ text: "hello",
513
+ })));
514
+ await new Promise((resolve) => setTimeout(resolve, 50));
515
+
516
+ const ownerUserFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: "owner-u" });
517
+ const directPrompt = (promptBuildResult as { appendSystemContext?: string }).appendSystemContext;
518
+ expect(ownerUserFile.exists).toBe(false);
519
+ expect(directPrompt).toContain("## Current ClawChat Owner Metadata");
520
+ expect(directPrompt).not.toContain("## Current ClawChat User Metadata");
521
+ expect(fetchMock).not.toHaveBeenCalledWith(
522
+ "https://api.example.com/v1/users/owner-u",
523
+ expect.anything(),
524
+ );
525
+
526
+ abortController.abort();
527
+ await run;
528
+ } finally {
529
+ fetchMock.mockRestore();
530
+ }
531
+ });
532
+
533
+ it("first group message pulls group metadata and participant users", async () => {
534
+ const memoryRoot = tempMemoryRoot();
535
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
536
+ const fetchMock = mockMetadataFetches();
537
+ const store = {
538
+ startConnection: vi.fn(() => 504),
539
+ markConnectSent: vi.fn(),
540
+ markConnectionReady: vi.fn(),
541
+ finishConnection: vi.fn(),
542
+ getCachedConversation: vi.fn(() => null),
543
+ upsertConversationSummary: vi.fn(),
544
+ upsertConversationDetails: vi.fn(),
545
+ claimMessageOnce: vi.fn(() => true),
546
+ };
547
+ const transport = new MockTransport();
548
+ const abortController = new AbortController();
549
+
550
+ try {
551
+ setOpenclawClawlingRuntime(runtime);
552
+ const run = startOpenclawClawlingGateway({
553
+ cfg: {},
554
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
555
+ abortSignal: abortController.signal,
556
+ setStatus: vi.fn(),
557
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
558
+ log: { info: vi.fn(), error: vi.fn() },
559
+ transport,
560
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
561
+ });
562
+
563
+ await completeHandshake(transport, "challenge-group-metadata");
564
+ transport.emitInbound(JSON.stringify(inboundMessageEnvelope({
565
+ chatId: "grp-memory",
566
+ chatType: "group",
567
+ messageId: "msg-group-metadata",
568
+ senderId: "participant-1",
569
+ text: "hello group",
570
+ mentions: ["u"],
571
+ })));
572
+ await new Promise((resolve) => setTimeout(resolve, 50));
573
+
574
+ const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: "grp-memory" });
575
+ const participantFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: "participant-1" });
576
+ const participantAgentFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: "participant-agent" });
577
+ expect(groupFile.metadata).toMatchObject({
578
+ id: "grp-memory",
579
+ title: "Room grp-memory",
580
+ description: "Description grp-memory",
581
+ });
582
+ expect(participantFile.metadata).toMatchObject({
583
+ id: "participant-1",
584
+ });
585
+ expect(participantAgentFile.metadata).toMatchObject({
586
+ id: "participant-agent",
587
+ });
588
+ expect(fetchMock).toHaveBeenCalledWith(
589
+ "https://api.example.com/v1/conversations/grp-memory",
590
+ expect.objectContaining({ method: "GET" }),
591
+ );
592
+
593
+ abortController.abort();
594
+ await run;
595
+ } finally {
596
+ fetchMock.mockRestore();
597
+ }
598
+ });
599
+
600
+ it("group messages refresh group metadata even when the conversation is cached", async () => {
601
+ const memoryRoot = tempMemoryRoot();
602
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
603
+ const fetchMock = mockMetadataFetches();
604
+ const store = {
605
+ startConnection: vi.fn(() => 505),
606
+ markConnectSent: vi.fn(),
607
+ markConnectionReady: vi.fn(),
608
+ finishConnection: vi.fn(),
609
+ getCachedConversation: vi.fn(() => ({ conversationId: "grp-cached" })),
610
+ upsertConversationSummary: vi.fn(),
611
+ upsertConversationDetails: vi.fn(),
612
+ claimMessageOnce: vi.fn(() => true),
613
+ };
614
+ const transport = new MockTransport();
615
+ const abortController = new AbortController();
616
+
617
+ try {
618
+ setOpenclawClawlingRuntime(runtime);
619
+ const run = startOpenclawClawlingGateway({
620
+ cfg: {},
621
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
622
+ abortSignal: abortController.signal,
623
+ setStatus: vi.fn(),
624
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
625
+ log: { info: vi.fn(), error: vi.fn() },
626
+ transport,
627
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
628
+ });
629
+
630
+ await completeHandshake(transport, "challenge-group-cached-metadata");
631
+ transport.emitInbound(JSON.stringify(inboundMessageEnvelope({
632
+ chatId: "grp-cached",
633
+ chatType: "group",
634
+ messageId: "msg-group-cached-metadata",
635
+ senderId: "participant-1",
636
+ text: "hello cached group",
637
+ mentions: ["u"],
638
+ })));
639
+ await new Promise((resolve) => setTimeout(resolve, 50));
640
+
641
+ expect(fetchMock).toHaveBeenCalledWith(
642
+ "https://api.example.com/v1/conversations/grp-cached",
643
+ expect.objectContaining({ method: "GET" }),
644
+ );
645
+ const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: "grp-cached" });
646
+ const participantFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: "participant-1" });
647
+ expect(groupFile.metadata).toMatchObject({
648
+ id: "grp-cached",
649
+ title: "Room grp-cached",
650
+ });
651
+ expect(participantFile.metadata).toMatchObject({
652
+ id: "participant-1",
653
+ });
654
+
655
+ abortController.abort();
656
+ await run;
657
+ } finally {
658
+ fetchMock.mockRestore();
659
+ }
660
+ });
661
+
662
+ it("metadata invalidation behavior scope pulls owner metadata", async () => {
663
+ const memoryRoot = tempMemoryRoot();
664
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
665
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig, memoryRoot);
666
+ const fetchMock = mockMetadataFetches();
667
+ const store = {
668
+ startConnection: vi.fn(() => 505),
669
+ markConnectSent: vi.fn(),
670
+ markConnectionReady: vi.fn(),
671
+ finishConnection: vi.fn(),
672
+ };
673
+ const transport = new MockTransport();
674
+ const abortController = new AbortController();
675
+
676
+ try {
677
+ setOpenclawClawlingRuntime(runtime);
678
+ const run = startOpenclawClawlingGateway({
679
+ cfg: {},
680
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
681
+ abortSignal: abortController.signal,
682
+ setStatus: vi.fn(),
683
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
684
+ log: { info: vi.fn(), error: vi.fn() },
685
+ transport,
686
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
687
+ });
688
+
689
+ await completeHandshake(transport, "challenge-behavior-file-metadata");
690
+ transport.emitInbound(JSON.stringify({
691
+ version: "2",
692
+ event: "chat.metadata.invalidated",
693
+ trace_id: "meta-owner-file",
694
+ emitted_at: Date.now(),
695
+ chat_id: "dm-owner",
696
+ chat_type: "direct",
697
+ payload: { scope: ["behavior"], version: 3 },
698
+ }));
699
+ await new Promise((resolve) => setTimeout(resolve, 50));
700
+
701
+ const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
702
+ expect(ownerFile.metadata).toMatchObject({
703
+ agent_id: "u",
704
+ behavior: "Use current owner metadata.",
705
+ });
706
+ expect(fetchMock).toHaveBeenCalledWith(
707
+ "https://api.example.com/v1/agents/agt-1",
708
+ expect.objectContaining({ method: "GET" }),
709
+ );
710
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
711
+
712
+ abortController.abort();
713
+ await run;
714
+ } finally {
715
+ fetchMock.mockRestore();
716
+ }
717
+ });
718
+
719
+ it.each([
720
+ ["title", ["title"]],
721
+ ["description", ["description"]],
722
+ ["unknown", ["unknown"]],
723
+ ["empty", []],
724
+ ])("metadata invalidation %s scope pulls group metadata", async (_name, scope) => {
725
+ const memoryRoot = tempMemoryRoot();
726
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
727
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig, memoryRoot);
728
+ const fetchMock = mockMetadataFetches();
729
+ const store = {
730
+ startConnection: vi.fn(() => 506),
731
+ markConnectSent: vi.fn(),
732
+ markConnectionReady: vi.fn(),
733
+ finishConnection: vi.fn(),
734
+ upsertConversationDetails: vi.fn(),
735
+ };
736
+ const transport = new MockTransport();
737
+ const abortController = new AbortController();
738
+
739
+ try {
740
+ setOpenclawClawlingRuntime(runtime);
741
+ const run = startOpenclawClawlingGateway({
742
+ cfg: {},
743
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
744
+ abortSignal: abortController.signal,
745
+ setStatus: vi.fn(),
746
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
747
+ log: { info: vi.fn(), error: vi.fn() },
748
+ transport,
749
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
750
+ });
751
+
752
+ await completeHandshake(transport, `challenge-group-file-${_name}`);
753
+ transport.emitInbound(JSON.stringify({
754
+ version: "2",
755
+ event: "chat.metadata.invalidated",
756
+ trace_id: `meta-group-file-${_name}`,
757
+ emitted_at: Date.now(),
758
+ chat_id: `grp-${_name}`,
759
+ chat_type: "group",
760
+ payload: { scope, version: 4 },
761
+ }));
762
+ await new Promise((resolve) => setTimeout(resolve, 50));
763
+
764
+ const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: `grp-${_name}` });
765
+ expect(groupFile.metadata).toMatchObject({
766
+ id: `grp-${_name}`,
767
+ title: `Room grp-${_name}`,
768
+ });
769
+ expect(fetchMock).toHaveBeenCalledWith(
770
+ `https://api.example.com/v1/conversations/grp-${_name}`,
771
+ expect.objectContaining({ method: "GET" }),
772
+ );
773
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
774
+
775
+ abortController.abort();
776
+ await run;
777
+ } finally {
778
+ fetchMock.mockRestore();
779
+ }
780
+ });
781
+ });
782
+
44
783
  describe("openclaw-clawchat runtime helpers", () => {
45
- it("maps SDK states to channel status shape", () => {
784
+ it("maps local client states to channel status shape", () => {
46
785
  expect(mapClawlingStateToStatus("connected")).toMatchObject({
47
786
  connected: true,
48
787
  running: true,
@@ -62,7 +801,7 @@ describe("openclaw-clawchat runtime helpers", () => {
62
801
  });
63
802
 
64
803
  it("classifies AuthError as fatal/no-retry", () => {
65
- const c = classifyClawlingClientError(new AuthError("hello-fail", "bad-token"));
804
+ const c = classifyClawlingClientError(new AuthError("bad-token"));
66
805
  expect(c.kind).toBe("auth");
67
806
  expect(c.retry).toBe(false);
68
807
  });
@@ -79,6 +818,40 @@ describe("openclaw-clawchat runtime helpers", () => {
79
818
  expect(getOpenclawClawlingRuntime()).toBe(rt);
80
819
  });
81
820
 
821
+ it("memory workspace accepts workspace_xxx roots from OpenClaw", () => {
822
+ const cfg = {} as OpenClawConfig;
823
+ const runtime = {
824
+ agent: {
825
+ resolveAgentWorkspaceDir: vi.fn(() => ".openclaw/workspace_01JABC"),
826
+ },
827
+ } as unknown as PluginRuntime;
828
+
829
+ expect(resolveClawChatMemoryRoot(runtime, cfg, "agent-a")).toBe(".openclaw/workspace_01JABC");
830
+ expect(runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(cfg, "agent-a");
831
+ });
832
+
833
+ it("memory workspace fails visibly when OpenClaw workspaceDir is missing", () => {
834
+ const cfg = {} as OpenClawConfig;
835
+ const runtime = {
836
+ agent: {
837
+ resolveAgentWorkspaceDir: vi.fn(() => " "),
838
+ },
839
+ } as unknown as PluginRuntime;
840
+
841
+ expect(() => resolveClawChatMemoryRoot(runtime, cfg, "agent-a")).toThrow(
842
+ "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved",
843
+ );
844
+ });
845
+
846
+ it("memory workspace does not fall back to the plugin package directory", () => {
847
+ const cfg = {} as OpenClawConfig;
848
+ const runtime = {} as unknown as PluginRuntime;
849
+
850
+ expect(() => resolveClawChatMemoryRoot(runtime, cfg, "agent-a")).toThrow(
851
+ "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved",
852
+ );
853
+ });
854
+
82
855
  it("logs auth_failed and does not reconnect after hello-fail", async () => {
83
856
  const logs: string[] = [];
84
857
  const transport = new MockTransport();
@@ -206,10 +979,19 @@ describe("openclaw-clawchat runtime helpers", () => {
206
979
  await run;
207
980
  });
208
981
 
209
- it("logs handshake_ok with the connect trace when hello-ok trace_id differs", async () => {
210
- const logs: string[] = [];
982
+ it("records websocket lifecycle calls in connection order", async () => {
983
+ const calls: string[] = [];
211
984
  const transport = new MockTransport();
212
985
  const abortController = new AbortController();
986
+ const store = {
987
+ startConnection: vi.fn(() => {
988
+ calls.push("startConnection");
989
+ return 101;
990
+ }),
991
+ markConnectSent: vi.fn(() => calls.push("markConnectSent")),
992
+ markConnectionReady: vi.fn(() => calls.push("markConnectionReady")),
993
+ finishConnection: vi.fn((_id, input) => calls.push(`finishConnection:${input.state}`)),
994
+ };
213
995
 
214
996
  setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
215
997
  const run = startOpenclawClawlingGateway({
@@ -218,8 +1000,9 @@ describe("openclaw-clawchat runtime helpers", () => {
218
1000
  abortSignal: abortController.signal,
219
1001
  setStatus: () => {},
220
1002
  getStatus: () => ({ connected: false, configured: true, running: true }),
221
- log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1003
+ log: { info: vi.fn(), error: vi.fn() },
222
1004
  transport,
1005
+ store,
223
1006
  });
224
1007
 
225
1008
  await Promise.resolve();
@@ -239,32 +1022,47 @@ describe("openclaw-clawchat runtime helpers", () => {
239
1022
  JSON.stringify({
240
1023
  version: "2",
241
1024
  event: "hello-ok",
242
- trace_id: "hello-different",
1025
+ trace_id: connectFrame.trace_id,
243
1026
  emitted_at: Date.now(),
244
1027
  payload: {},
245
1028
  }),
246
1029
  );
247
1030
  await Promise.resolve();
248
1031
 
249
- expect(logs).toContainEqual(
250
- expect.stringMatching(
251
- new RegExp(
252
- "^clawchat\\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=0 state=ready action=flush_queue trace_id=" +
253
- connectFrame.trace_id +
254
- " elapsed_ms=\\d+ queue_size=0$",
255
- ),
256
- ),
257
- );
258
- expect(logs.some((line) => line.includes("trace_id=hello-different elapsed_ms="))).toBe(false);
259
-
260
1032
  abortController.abort();
261
1033
  await run;
1034
+
1035
+ expect(calls).toEqual([
1036
+ "startConnection",
1037
+ "markConnectSent",
1038
+ "markConnectionReady",
1039
+ "finishConnection:disconnected",
1040
+ ]);
1041
+ expect(store.startConnection).toHaveBeenCalledWith(
1042
+ expect.objectContaining({
1043
+ platform: "openclaw",
1044
+ accountId: "default",
1045
+ attempt: 1,
1046
+ reconnectCount: 0,
1047
+ }),
1048
+ );
1049
+ expect(store.markConnectSent).toHaveBeenCalledWith(101);
1050
+ expect(store.markConnectionReady).toHaveBeenCalledWith(101);
1051
+ expect(store.finishConnection).toHaveBeenCalledWith(
1052
+ 101,
1053
+ expect.objectContaining({ state: "disconnected", closeCode: 1000 }),
1054
+ );
262
1055
  });
263
1056
 
264
- it("logs JSON ping and pong as protocol control", async () => {
265
- const logs: string[] = [];
1057
+ it("records hello-ok device metadata when marking a connection ready", async () => {
266
1058
  const transport = new MockTransport();
267
1059
  const abortController = new AbortController();
1060
+ const store = {
1061
+ startConnection: vi.fn(() => 111),
1062
+ markConnectSent: vi.fn(),
1063
+ markConnectionReady: vi.fn(),
1064
+ finishConnection: vi.fn(),
1065
+ };
268
1066
 
269
1067
  setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
270
1068
  const run = startOpenclawClawlingGateway({
@@ -273,8 +1071,9 @@ describe("openclaw-clawchat runtime helpers", () => {
273
1071
  abortSignal: abortController.signal,
274
1072
  setStatus: () => {},
275
1073
  getStatus: () => ({ connected: false, configured: true, running: true }),
276
- log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1074
+ log: { info: vi.fn(), error: vi.fn() },
277
1075
  transport,
1076
+ store,
278
1077
  });
279
1078
 
280
1079
  await Promise.resolve();
@@ -296,117 +1095,984 @@ describe("openclaw-clawchat runtime helpers", () => {
296
1095
  event: "hello-ok",
297
1096
  trace_id: connectFrame.trace_id,
298
1097
  emitted_at: Date.now(),
299
- payload: {},
1098
+ payload: { device_id: "device-resolved", delivery_mode: "device_replay" },
300
1099
  }),
301
1100
  );
302
1101
  await Promise.resolve();
303
- transport.sent.length = 0;
304
1102
 
305
- transport.emitInbound(
306
- JSON.stringify({
307
- version: "2",
308
- event: "ping",
309
- trace_id: "trace-ping",
310
- emitted_at: Date.now(),
311
- payload: {},
1103
+ abortController.abort();
1104
+ await run;
1105
+
1106
+ expect(store.markConnectionReady).toHaveBeenCalledWith(
1107
+ 111,
1108
+ expect.objectContaining({
1109
+ resolvedDeviceId: "device-resolved",
1110
+ deliveryMode: "device_replay",
312
1111
  }),
313
1112
  );
314
- transport.emitInbound(
315
- JSON.stringify({
1113
+ });
1114
+
1115
+ it("refreshes metadata invalidations without dispatching an agent turn", async () => {
1116
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
1117
+ const memoryRoot = tempMemoryRoot();
1118
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig, memoryRoot);
1119
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1120
+ jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-1") } }),
1121
+ );
1122
+ const store = {
1123
+ startConnection: vi.fn(() => 121),
1124
+ markConnectSent: vi.fn(),
1125
+ markConnectionReady: vi.fn(),
1126
+ finishConnection: vi.fn(),
1127
+ getCachedConversation: vi.fn(() => ({
1128
+ conversationId: "group-1",
1129
+ conversationType: "group",
1130
+ metadataVersion: 4,
1131
+ lastSeenAt: 1,
1132
+ lastRefreshedAt: 1,
1133
+ })),
1134
+ upsertConversationDetails: vi.fn(),
1135
+ deleteConversationCache: vi.fn(),
1136
+ };
1137
+ const transport = new MockTransport();
1138
+ const abortController = new AbortController();
1139
+
1140
+ try {
1141
+ setOpenclawClawlingRuntime(runtime);
1142
+ const run = startOpenclawClawlingGateway({
1143
+ cfg: {},
1144
+ account: baseAccount(),
1145
+ abortSignal: abortController.signal,
1146
+ setStatus: vi.fn(),
1147
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1148
+ log: { info: vi.fn(), error: vi.fn() },
1149
+ transport,
1150
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1151
+ });
1152
+
1153
+ await completeHandshake(transport, "challenge-meta-refresh");
1154
+ transport.emitInbound(JSON.stringify({
316
1155
  version: "2",
317
- event: "pong",
318
- trace_id: "trace-pong",
1156
+ event: "chat.metadata.invalidated",
1157
+ trace_id: "meta-refresh",
319
1158
  emitted_at: Date.now(),
320
- payload: {},
321
- }),
322
- );
1159
+ chat_id: "group-1",
1160
+ chat_type: "group",
1161
+ payload: { scope: ["unknown"], version: 7 },
1162
+ }));
1163
+ await new Promise((resolve) => setTimeout(resolve, 30));
323
1164
 
324
- expect(transport.sent.map((raw) => JSON.parse(raw))).toContainEqual(
325
- expect.objectContaining({ event: "pong", trace_id: "trace-ping" }),
326
- );
327
- expect(logs).toContain(
328
- "clawchat.ws event=protocol_ping_received account_id=default attempt=1 reconnect_count=0 state=ready action=send_pong trace_id=trace-ping",
329
- );
330
- expect(logs).toContain(
331
- "clawchat.ws event=protocol_pong_received account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-pong",
332
- );
1165
+ expect(fetchMock).toHaveBeenCalledWith(
1166
+ "https://api.example.com/v1/conversations/group-1",
1167
+ expect.objectContaining({ method: "GET" }),
1168
+ );
1169
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1170
+ platform: "openclaw",
1171
+ accountId: "default",
1172
+ conversationId: "group-1",
1173
+ conversationType: "group",
1174
+ metadataVersion: 7,
1175
+ members: [expect.objectContaining({ userId: "user-owner", role: "owner" })],
1176
+ membersComplete: true,
1177
+ }));
1178
+ await expect(readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: "group-1" }))
1179
+ .resolves.toMatchObject({ metadata: expect.objectContaining({ title: "Room group-1" }) });
1180
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
333
1181
 
334
- abortController.abort();
335
- await run;
1182
+ abortController.abort();
1183
+ await run;
1184
+ } finally {
1185
+ fetchMock.mockRestore();
1186
+ }
336
1187
  });
337
1188
 
338
- it("logs unknown ready-state events as inbound_ignored", async () => {
1189
+ it("logs metadata invalidations without chat_id and treats stale versions as pull signals", async () => {
339
1190
  const logs: string[] = [];
1191
+ const memoryRoot = tempMemoryRoot();
1192
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
1193
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1194
+ jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-stale") } }),
1195
+ );
1196
+ const store = {
1197
+ startConnection: vi.fn(() => 122),
1198
+ markConnectSent: vi.fn(),
1199
+ markConnectionReady: vi.fn(),
1200
+ finishConnection: vi.fn(),
1201
+ getCachedConversation: vi.fn(() => ({
1202
+ conversationId: "group-stale",
1203
+ conversationType: "group",
1204
+ metadataVersion: 9,
1205
+ lastSeenAt: 1,
1206
+ lastRefreshedAt: 1,
1207
+ })),
1208
+ upsertConversationDetails: vi.fn(),
1209
+ };
340
1210
  const transport = new MockTransport();
341
1211
  const abortController = new AbortController();
342
1212
 
343
- setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
344
- const run = startOpenclawClawlingGateway({
345
- cfg: {},
346
- account: baseAccount(),
347
- abortSignal: abortController.signal,
348
- setStatus: () => {},
349
- getStatus: () => ({ connected: false, configured: true, running: true }),
350
- log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
351
- transport,
352
- });
1213
+ try {
1214
+ setOpenclawClawlingRuntime(runtime);
1215
+ const run = startOpenclawClawlingGateway({
1216
+ cfg: {},
1217
+ account: baseAccount(),
1218
+ abortSignal: abortController.signal,
1219
+ setStatus: vi.fn(),
1220
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1221
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1222
+ transport,
1223
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1224
+ });
353
1225
 
354
- await Promise.resolve();
355
- transport.emitInbound(
356
- JSON.stringify({
1226
+ await completeHandshake(transport, "challenge-meta-stale");
1227
+ transport.emitInbound(JSON.stringify({
357
1228
  version: "2",
358
- event: "connect.challenge",
359
- trace_id: "challenge-1",
1229
+ event: "chat.metadata.invalidated",
1230
+ trace_id: "meta-missing-chat",
360
1231
  emitted_at: Date.now(),
361
- payload: { nonce: "nonce-1" },
362
- }),
363
- );
364
- const connectFrame = transport.sent
365
- .map((raw) => JSON.parse(raw))
366
- .find((env) => env.event === "connect");
367
- transport.emitInbound(
368
- JSON.stringify({
1232
+ payload: { version: 10 },
1233
+ }));
1234
+ transport.emitInbound(JSON.stringify({
369
1235
  version: "2",
370
- event: "hello-ok",
371
- trace_id: connectFrame.trace_id,
1236
+ event: "chat.metadata.invalidated",
1237
+ trace_id: "meta-stale",
372
1238
  emitted_at: Date.now(),
373
- payload: {},
374
- }),
375
- );
376
- await Promise.resolve();
1239
+ chat_id: "group-stale",
1240
+ payload: { version: 9 },
1241
+ }));
1242
+ await new Promise((resolve) => setTimeout(resolve, 30));
377
1243
 
378
- transport.emitInbound(
379
- JSON.stringify({
1244
+ expect(fetchMock).toHaveBeenCalledWith(
1245
+ "https://api.example.com/v1/conversations/group-stale",
1246
+ expect.objectContaining({ method: "GET" }),
1247
+ );
1248
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1249
+ conversationId: "group-stale",
1250
+ }));
1251
+ expect(logs.some((line) => line.includes("metadata invalidation missing chat_id"))).toBe(true);
1252
+
1253
+ abortController.abort();
1254
+ await run;
1255
+ } finally {
1256
+ fetchMock.mockRestore();
1257
+ }
1258
+ });
1259
+
1260
+ it("refreshes metadata invalidations without a version and deletes scoped cache on not found", async () => {
1261
+ const runtime = buildNoDispatchRuntime();
1262
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
1263
+ const url = String(input);
1264
+ if (url.endsWith("/v1/conversations/group-no-version")) {
1265
+ return jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-no-version") } });
1266
+ }
1267
+ if (url.endsWith("/v1/users/user-owner")) {
1268
+ return jsonEnvelope({ code: 0, msg: "ok", data: { id: "user-owner", nickname: "Owner" } });
1269
+ }
1270
+ if (url.endsWith("/v1/conversations/group-missing")) {
1271
+ return jsonEnvelope({ code: 404, msg: "conversation not found", data: {} });
1272
+ }
1273
+ return jsonEnvelope({ code: 0, msg: "ok", data: { agent: { id: "agt-1" } } });
1274
+ });
1275
+ const store = {
1276
+ startConnection: vi.fn(() => 123),
1277
+ markConnectSent: vi.fn(),
1278
+ markConnectionReady: vi.fn(),
1279
+ finishConnection: vi.fn(),
1280
+ getCachedConversation: vi.fn(() => ({
1281
+ conversationId: "group-no-version",
1282
+ conversationType: "group",
1283
+ metadataVersion: 99,
1284
+ lastSeenAt: 1,
1285
+ lastRefreshedAt: 1,
1286
+ })),
1287
+ upsertConversationDetails: vi.fn(),
1288
+ deleteConversationCache: vi.fn(),
1289
+ };
1290
+ const transport = new MockTransport();
1291
+ const abortController = new AbortController();
1292
+
1293
+ try {
1294
+ setOpenclawClawlingRuntime(runtime);
1295
+ const run = startOpenclawClawlingGateway({
1296
+ cfg: {},
1297
+ account: baseAccount(),
1298
+ abortSignal: abortController.signal,
1299
+ setStatus: vi.fn(),
1300
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1301
+ log: { info: vi.fn(), error: vi.fn() },
1302
+ transport,
1303
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1304
+ });
1305
+
1306
+ await completeHandshake(transport, "challenge-meta-noversion");
1307
+ transport.emitInbound(JSON.stringify({
380
1308
  version: "2",
381
- event: "custom.event",
382
- trace_id: "trace-custom",
1309
+ event: "chat.metadata.invalidated",
1310
+ trace_id: "meta-no-version",
383
1311
  emitted_at: Date.now(),
1312
+ chat_id: "group-no-version",
384
1313
  payload: {},
385
- }),
386
- );
1314
+ }));
1315
+ transport.emitInbound(JSON.stringify({
1316
+ version: "2",
1317
+ event: "chat.metadata.invalidated",
1318
+ trace_id: "meta-not-found",
1319
+ emitted_at: Date.now(),
1320
+ chat_id: "group-missing",
1321
+ payload: { version: 100 },
1322
+ }));
1323
+ await new Promise((resolve) => setTimeout(resolve, 10));
387
1324
 
388
- expect(logs).toContain(
389
- "clawchat.ws event=inbound_ignored account_id=default attempt=1 reconnect_count=0 state=ready action=ignore event_name=custom.event trace_id=trace-custom",
390
- );
1325
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1326
+ conversationId: "group-no-version",
1327
+ }));
1328
+ expect(store.deleteConversationCache).toHaveBeenCalledWith({
1329
+ platform: "openclaw",
1330
+ accountId: "default",
1331
+ conversationId: "group-missing",
1332
+ });
391
1333
 
392
- abortController.abort();
393
- await run;
1334
+ abortController.abort();
1335
+ await run;
1336
+ } finally {
1337
+ fetchMock.mockRestore();
1338
+ }
394
1339
  });
395
1340
 
396
- it("auto flushes queued outbound when runtime observes connected", async () => {
397
- const logs: string[] = [];
1341
+ it("clears group description on metadata invalidation when conversation detail returns explicit null", async () => {
1342
+ const memoryRoot = tempMemoryRoot();
1343
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
1344
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1345
+ jsonEnvelope({
1346
+ code: 0,
1347
+ msg: "ok",
1348
+ data: { conversation: conversationDetails("group-clear-description", { description: null }) },
1349
+ }),
1350
+ );
1351
+ const store = {
1352
+ startConnection: vi.fn(() => 126),
1353
+ markConnectSent: vi.fn(),
1354
+ markConnectionReady: vi.fn(),
1355
+ finishConnection: vi.fn(),
1356
+ getCachedConversation: vi.fn(() => ({
1357
+ conversationId: "group-clear-description",
1358
+ conversationType: "group",
1359
+ metadataVersion: 5,
1360
+ lastSeenAt: 1,
1361
+ lastRefreshedAt: 1,
1362
+ })),
1363
+ upsertConversationDetails: vi.fn(),
1364
+ };
398
1365
  const transport = new MockTransport();
399
1366
  const abortController = new AbortController();
400
1367
 
401
- setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
1368
+ try {
1369
+ setOpenclawClawlingRuntime(runtime);
1370
+ const run = startOpenclawClawlingGateway({
1371
+ cfg: {},
1372
+ account: baseAccount(),
1373
+ abortSignal: abortController.signal,
1374
+ setStatus: vi.fn(),
1375
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1376
+ log: { info: vi.fn(), error: vi.fn() },
1377
+ transport,
1378
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1379
+ });
1380
+
1381
+ await completeHandshake(transport, "challenge-meta-description-null");
1382
+ transport.emitInbound(JSON.stringify({
1383
+ version: "2",
1384
+ event: "chat.metadata.invalidated",
1385
+ trace_id: "meta-description-null",
1386
+ emitted_at: Date.now(),
1387
+ chat_id: "group-clear-description",
1388
+ payload: { scope: ["description"], version: 6 },
1389
+ }));
1390
+ await new Promise((resolve) => setTimeout(resolve, 10));
1391
+
1392
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1393
+ conversationId: "group-clear-description",
1394
+ }));
1395
+ const groupFile = await readClawChatMemoryFile(memoryRoot, {
1396
+ targetType: "group",
1397
+ targetId: "group-clear-description",
1398
+ });
1399
+ expect(groupFile.metadata).toMatchObject({ id: "group-clear-description" });
1400
+ expect(groupFile.metadata).not.toHaveProperty("description");
1401
+
1402
+ abortController.abort();
1403
+ await run;
1404
+ } finally {
1405
+ fetchMock.mockRestore();
1406
+ }
1407
+ });
1408
+
1409
+ it("refreshes behavior invalidations from the agent endpoint and stores behavior in owner metadata", async () => {
1410
+ const memoryRoot = tempMemoryRoot();
1411
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
1412
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1413
+ jsonEnvelope({
1414
+ code: 0,
1415
+ msg: "ok",
1416
+ data: {
1417
+ agent: {
1418
+ id: "agent-row",
1419
+ user_id: "u",
1420
+ owner_id: "owner-u",
1421
+ type: "agent",
1422
+ nickname: "Hermes",
1423
+ avatar_url: "https://example.test/hermes.png",
1424
+ bio: "Agent bio",
1425
+ behavior: "Use updated behavior.",
1426
+ },
1427
+ },
1428
+ }),
1429
+ );
1430
+ const store = {
1431
+ startConnection: vi.fn(() => 127),
1432
+ markConnectSent: vi.fn(),
1433
+ markConnectionReady: vi.fn(),
1434
+ finishConnection: vi.fn(),
1435
+ upsertConversationDetails: vi.fn(),
1436
+ deleteConversationCache: vi.fn(),
1437
+ };
1438
+ const transport = new MockTransport();
1439
+ const abortController = new AbortController();
1440
+
1441
+ try {
1442
+ setOpenclawClawlingRuntime(runtime);
1443
+ const run = startOpenclawClawlingGateway({
1444
+ cfg: {},
1445
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
1446
+ abortSignal: abortController.signal,
1447
+ setStatus: vi.fn(),
1448
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1449
+ log: { info: vi.fn(), error: vi.fn() },
1450
+ transport,
1451
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1452
+ });
1453
+
1454
+ await completeHandshake(transport, "challenge-behavior-meta");
1455
+ transport.emitInbound(JSON.stringify({
1456
+ version: "2",
1457
+ event: "chat.metadata.invalidated",
1458
+ trace_id: "meta-behavior",
1459
+ emitted_at: Date.now(),
1460
+ chat_id: "dm-owner-agent",
1461
+ chat_type: "direct",
1462
+ payload: { scope: ["behavior"], version: 6 },
1463
+ }));
1464
+ await new Promise((resolve) => setTimeout(resolve, 10));
1465
+
1466
+ expect(fetchMock).toHaveBeenCalledWith(
1467
+ "https://api.example.com/v1/agents/agt-1",
1468
+ expect.objectContaining({ method: "GET" }),
1469
+ );
1470
+ expect(fetchMock).not.toHaveBeenCalledWith(
1471
+ "https://api.example.com/v1/conversations/dm-owner-agent",
1472
+ expect.anything(),
1473
+ );
1474
+ const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
1475
+ expect(ownerFile.metadata).toMatchObject({
1476
+ agent_id: "u",
1477
+ owner_id: "owner-u",
1478
+ nickname: "Hermes",
1479
+ avatar_url: "https://example.test/hermes.png",
1480
+ bio: "Agent bio",
1481
+ behavior: "Use updated behavior.",
1482
+ });
1483
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
1484
+
1485
+ abortController.abort();
1486
+ await run;
1487
+ } finally {
1488
+ fetchMock.mockRestore();
1489
+ }
1490
+ });
1491
+
1492
+ it("clears behavior on metadata invalidation when the agent endpoint returns explicit null", async () => {
1493
+ const memoryRoot = tempMemoryRoot();
1494
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
1495
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1496
+ jsonEnvelope({
1497
+ code: 0,
1498
+ msg: "ok",
1499
+ data: {
1500
+ agent: {
1501
+ user_id: "u",
1502
+ owner_id: "owner-u",
1503
+ type: "agent",
1504
+ nickname: "Hermes",
1505
+ behavior: null,
1506
+ },
1507
+ },
1508
+ }),
1509
+ );
1510
+ const store = {
1511
+ startConnection: vi.fn(() => 132),
1512
+ markConnectSent: vi.fn(),
1513
+ markConnectionReady: vi.fn(),
1514
+ finishConnection: vi.fn(),
1515
+ };
1516
+ const transport = new MockTransport();
1517
+ const abortController = new AbortController();
1518
+
1519
+ try {
1520
+ setOpenclawClawlingRuntime(runtime);
1521
+ const run = startOpenclawClawlingGateway({
1522
+ cfg: {},
1523
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
1524
+ abortSignal: abortController.signal,
1525
+ setStatus: vi.fn(),
1526
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1527
+ log: { info: vi.fn(), error: vi.fn() },
1528
+ transport,
1529
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1530
+ });
1531
+
1532
+ await completeHandshake(transport, "challenge-behavior-null-meta");
1533
+ transport.emitInbound(JSON.stringify({
1534
+ version: "2",
1535
+ event: "chat.metadata.invalidated",
1536
+ trace_id: "meta-behavior-null",
1537
+ emitted_at: Date.now(),
1538
+ chat_id: "dm-owner-agent",
1539
+ chat_type: "direct",
1540
+ payload: { scope: ["behavior"], version: 6 },
1541
+ }));
1542
+ await new Promise((resolve) => setTimeout(resolve, 10));
1543
+
1544
+ const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
1545
+ expect(ownerFile.metadata).toMatchObject({
1546
+ agent_id: "u",
1547
+ owner_id: "owner-u",
1548
+ nickname: "Hermes",
1549
+ });
1550
+ expect(ownerFile.metadata).not.toHaveProperty("behavior");
1551
+ abortController.abort();
1552
+ await run;
1553
+ } finally {
1554
+ fetchMock.mockRestore();
1555
+ }
1556
+ });
1557
+
1558
+ it("ordinary direct messages refresh user metadata on each message", async () => {
1559
+ const memoryRoot = tempMemoryRoot();
1560
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
1561
+ const requestedUrls: string[] = [];
1562
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
1563
+ requestedUrls.push(String(input));
1564
+ const requestNumber = requestedUrls.length;
1565
+ return jsonEnvelope({
1566
+ code: 0,
1567
+ msg: "ok",
1568
+ data: {
1569
+ id: "user-1",
1570
+ type: "user",
1571
+ nickname: requestNumber === 1 ? "User One" : "User One Updated",
1572
+ avatar_url: "https://example.test/u.png",
1573
+ bio: requestNumber === 1 ? "Bio" : "Updated Bio",
1574
+ },
1575
+ });
1576
+ });
1577
+ const knownChats = new Set<string>();
1578
+ const store = {
1579
+ startConnection: vi.fn(() => 128),
1580
+ markConnectSent: vi.fn(),
1581
+ markConnectionReady: vi.fn(),
1582
+ finishConnection: vi.fn(),
1583
+ getCachedConversation: vi.fn((input: { conversationId: string }) =>
1584
+ knownChats.has(input.conversationId) ? { conversationId: input.conversationId } : null
1585
+ ),
1586
+ upsertConversationSummary: vi.fn((input: { conversationId: string }) => knownChats.add(input.conversationId)),
1587
+ upsertConversationDetails: vi.fn(),
1588
+ };
1589
+ const transport = new MockTransport();
1590
+ const abortController = new AbortController();
1591
+
1592
+ try {
1593
+ setOpenclawClawlingRuntime(runtime);
1594
+ const run = startOpenclawClawlingGateway({
1595
+ cfg: {},
1596
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
1597
+ abortSignal: abortController.signal,
1598
+ setStatus: vi.fn(),
1599
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1600
+ log: { info: vi.fn(), error: vi.fn() },
1601
+ transport,
1602
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1603
+ });
1604
+
1605
+ await completeHandshake(transport, "challenge-direct-profile");
1606
+ for (const messageId of ["m-direct-1", "m-direct-2"]) {
1607
+ transport.emitInbound(JSON.stringify({
1608
+ version: "2",
1609
+ event: "message.send",
1610
+ trace_id: messageId,
1611
+ emitted_at: Date.now(),
1612
+ chat_id: "dm-1",
1613
+ chat_type: "direct",
1614
+ sender: { id: "user-1", type: "direct", nick_name: "User One" },
1615
+ payload: {
1616
+ message_id: messageId,
1617
+ message_mode: "normal",
1618
+ message: {
1619
+ body: { fragments: [{ kind: "text", text: "hello" }] },
1620
+ context: { mentions: [], reply: null },
1621
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
1622
+ },
1623
+ },
1624
+ }));
1625
+ await new Promise((resolve) => setTimeout(resolve, 20));
1626
+ }
1627
+
1628
+ expect(requestedUrls).toEqual([
1629
+ "https://api.example.com/v1/users/user-1",
1630
+ "https://api.example.com/v1/users/user-1",
1631
+ ]);
1632
+ const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: "user-1" });
1633
+ expect(userFile.metadata).toMatchObject({
1634
+ id: "user-1",
1635
+ nickname: "User One Updated",
1636
+ avatar_url: "https://example.test/u.png",
1637
+ bio: "Updated Bio",
1638
+ profile_type: "user",
1639
+ });
1640
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
1641
+
1642
+ abortController.abort();
1643
+ await run;
1644
+ } finally {
1645
+ fetchMock.mockRestore();
1646
+ }
1647
+ });
1648
+
1649
+ it("waits for first-seen user detail before building the direct prompt", async () => {
1650
+ const handlers = new Map<string, Function>();
1651
+ registerClawChatPromptInjection({
1652
+ on: vi.fn((name: string, handler: Function) => handlers.set(name, handler)),
1653
+ });
1654
+ let promptBuildResult: unknown;
1655
+ const dispatchReplyFromConfig = vi.fn(async () => {
1656
+ promptBuildResult = await handlers.get("before_prompt_build")?.({}, { sessionKey: "session-from-route" });
1657
+ });
1658
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
1659
+ const requestedUrls: string[] = [];
1660
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
1661
+ requestedUrls.push(String(input));
1662
+ await new Promise((resolve) => setTimeout(resolve, 20));
1663
+ return jsonEnvelope({
1664
+ code: 0,
1665
+ msg: "ok",
1666
+ data: {
1667
+ id: "user-1",
1668
+ type: "user",
1669
+ nickname: "Fetched User",
1670
+ avatar_url: "https://example.test/fetched.png",
1671
+ bio: "Fetched bio",
1672
+ },
1673
+ });
1674
+ });
1675
+ const store = {
1676
+ startConnection: vi.fn(() => 130),
1677
+ markConnectSent: vi.fn(),
1678
+ markConnectionReady: vi.fn(),
1679
+ finishConnection: vi.fn(),
1680
+ getCachedConversation: vi.fn(() => null),
1681
+ upsertConversationSummary: vi.fn(),
1682
+ upsertConversationDetails: vi.fn(),
1683
+ };
1684
+ const transport = new MockTransport();
1685
+ const abortController = new AbortController();
1686
+
1687
+ try {
1688
+ setOpenclawClawlingRuntime(runtime);
1689
+ const run = startOpenclawClawlingGateway({
1690
+ cfg: {},
1691
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
1692
+ abortSignal: abortController.signal,
1693
+ setStatus: vi.fn(),
1694
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1695
+ log: { info: vi.fn(), error: vi.fn() },
1696
+ transport,
1697
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1698
+ });
1699
+
1700
+ await completeHandshake(transport, "challenge-direct-prompt-profile");
1701
+ transport.emitInbound(JSON.stringify({
1702
+ version: "2",
1703
+ event: "message.send",
1704
+ trace_id: "m-direct-prompt-profile",
1705
+ emitted_at: Date.now(),
1706
+ chat_id: "dm-1",
1707
+ chat_type: "direct",
1708
+ sender: { id: "user-1", type: "direct", nick_name: "Fallback User" },
1709
+ payload: {
1710
+ message_id: "m-direct-prompt-profile",
1711
+ message_mode: "normal",
1712
+ message: {
1713
+ body: { fragments: [{ kind: "text", text: "hello" }] },
1714
+ context: { mentions: [], reply: null },
1715
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
1716
+ },
1717
+ },
1718
+ }));
1719
+ await new Promise((resolve) => setTimeout(resolve, 80));
1720
+
1721
+ expect(requestedUrls).toEqual(["https://api.example.com/v1/users/user-1"]);
1722
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1723
+ const directPrompt = (promptBuildResult as { appendSystemContext?: string }).appendSystemContext;
1724
+ expect(directPrompt).toContain("## Current ClawChat User Metadata");
1725
+ expect(directPrompt).toContain("nickname: Fetched User");
1726
+ expect(directPrompt).toContain("avatar_url: https://example.test/fetched.png");
1727
+ expect(directPrompt).toContain("bio: Fetched bio");
1728
+ expect(directPrompt).toContain("chat_type: dm");
1729
+
1730
+ abortController.abort();
1731
+ await run;
1732
+ } finally {
1733
+ fetchMock.mockRestore();
1734
+ }
1735
+ });
1736
+
1737
+ it("uses dm plus sender_is_owner for owner direct messages", async () => {
1738
+ const handlers = new Map<string, Function>();
1739
+ registerClawChatPromptInjection({
1740
+ on: vi.fn((name: string, handler: Function) => handlers.set(name, handler)),
1741
+ });
1742
+ let promptBuildResult: unknown;
1743
+ const dispatchReplyFromConfig = vi.fn(async () => {
1744
+ promptBuildResult = await handlers.get("before_prompt_build")?.({}, { sessionKey: "session-from-route" });
1745
+ });
1746
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
1747
+ const store = {
1748
+ startConnection: vi.fn(() => 131),
1749
+ markConnectSent: vi.fn(),
1750
+ markConnectionReady: vi.fn(),
1751
+ finishConnection: vi.fn(),
1752
+ getCachedConversation: vi.fn(() => ({ conversationId: "dm-owner" })),
1753
+ upsertConversationSummary: vi.fn(),
1754
+ };
1755
+ const transport = new MockTransport();
1756
+ const abortController = new AbortController();
1757
+
1758
+ setOpenclawClawlingRuntime(runtime);
402
1759
  const run = startOpenclawClawlingGateway({
403
1760
  cfg: {},
404
- account: baseAccount({ ack: { timeout: 15000, autoResendOnTimeout: false } }),
1761
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
1762
+ abortSignal: abortController.signal,
1763
+ setStatus: vi.fn(),
1764
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1765
+ log: { info: vi.fn(), error: vi.fn() },
1766
+ transport,
1767
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1768
+ });
1769
+
1770
+ await completeHandshake(transport, "challenge-owner-dm-prompt");
1771
+ transport.emitInbound(JSON.stringify({
1772
+ version: "2",
1773
+ event: "message.send",
1774
+ trace_id: "m-owner-dm-prompt",
1775
+ emitted_at: Date.now(),
1776
+ chat_id: "dm-owner",
1777
+ chat_type: "direct",
1778
+ sender: { id: "owner-u", type: "direct", nick_name: "Owner" },
1779
+ payload: {
1780
+ message_id: "m-owner-dm-prompt",
1781
+ message_mode: "normal",
1782
+ message: {
1783
+ body: { fragments: [{ kind: "text", text: "hello" }] },
1784
+ context: { mentions: [], reply: null },
1785
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
1786
+ },
1787
+ },
1788
+ }));
1789
+ await new Promise((resolve) => setTimeout(resolve, 30));
1790
+ abortController.abort();
1791
+ await run;
1792
+
1793
+ const directPrompt = (promptBuildResult as { appendSystemContext?: string }).appendSystemContext;
1794
+ expect(directPrompt).toContain("chat_type: dm");
1795
+ expect(directPrompt).toContain("sender_is_owner: true");
1796
+ expect(directPrompt).not.toContain("owner_dm");
1797
+ expect(directPrompt).not.toContain("sender_relation:");
1798
+ });
1799
+
1800
+ it("first group metadata sync fetches conversation detail and saves group metadata", async () => {
1801
+ const memoryRoot = tempMemoryRoot();
1802
+ const runtime = buildNoDispatchRuntime(vi.fn().mockResolvedValue(undefined), memoryRoot);
1803
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1804
+ jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("grp-profile") } }),
1805
+ );
1806
+ const store = {
1807
+ startConnection: vi.fn(() => 129),
1808
+ markConnectSent: vi.fn(),
1809
+ markConnectionReady: vi.fn(),
1810
+ finishConnection: vi.fn(),
1811
+ getCachedConversation: vi.fn(() => null),
1812
+ upsertConversationSummary: vi.fn(),
1813
+ upsertConversationDetails: vi.fn(),
1814
+ };
1815
+ const transport = new MockTransport();
1816
+ const abortController = new AbortController();
1817
+
1818
+ try {
1819
+ setOpenclawClawlingRuntime(runtime);
1820
+ const run = startOpenclawClawlingGateway({
1821
+ cfg: {},
1822
+ account: baseAccount({ userId: "u", ownerUserId: "owner-u" }),
1823
+ abortSignal: abortController.signal,
1824
+ setStatus: vi.fn(),
1825
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1826
+ log: { info: vi.fn(), error: vi.fn() },
1827
+ transport,
1828
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1829
+ });
1830
+
1831
+ await completeHandshake(transport, "challenge-group-profile");
1832
+ transport.emitInbound(JSON.stringify({
1833
+ version: "2",
1834
+ event: "message.send",
1835
+ trace_id: "m-group-profile",
1836
+ emitted_at: Date.now(),
1837
+ chat_id: "grp-profile",
1838
+ chat_type: "group",
1839
+ sender: { id: "user-1", type: "direct", nick_name: "User One" },
1840
+ payload: {
1841
+ message_id: "m-group-profile",
1842
+ message_mode: "normal",
1843
+ message: {
1844
+ body: { fragments: [{ kind: "text", text: "hello group" }] },
1845
+ context: { mentions: ["u"], reply: null },
1846
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
1847
+ },
1848
+ },
1849
+ }));
1850
+ await new Promise((resolve) => setTimeout(resolve, 30));
1851
+
1852
+ expect(fetchMock).toHaveBeenCalledWith(
1853
+ "https://api.example.com/v1/conversations/grp-profile",
1854
+ expect.objectContaining({ method: "GET" }),
1855
+ );
1856
+ expect(fetchMock).toHaveBeenCalledWith(
1857
+ "https://api.example.com/v1/users/user-owner",
1858
+ expect.objectContaining({ method: "GET" }),
1859
+ );
1860
+ expect(fetchMock).toHaveBeenCalledTimes(2);
1861
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1862
+ conversationId: "grp-profile",
1863
+ }));
1864
+ const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: "grp-profile" });
1865
+ expect(groupFile.metadata).toMatchObject({
1866
+ id: "grp-profile",
1867
+ title: "Room grp-profile",
1868
+ });
1869
+
1870
+ abortController.abort();
1871
+ await run;
1872
+ } finally {
1873
+ fetchMock.mockRestore();
1874
+ }
1875
+ });
1876
+
1877
+ it("logs metadata refresh errors without advancing the cached version", async () => {
1878
+ const logs: string[] = [];
1879
+ const runtime = buildNoDispatchRuntime();
1880
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
1881
+ new Response("gateway unavailable", { status: 500 }),
1882
+ );
1883
+ const store = {
1884
+ startConnection: vi.fn(() => 124),
1885
+ markConnectSent: vi.fn(),
1886
+ markConnectionReady: vi.fn(),
1887
+ finishConnection: vi.fn(),
1888
+ getCachedConversation: vi.fn(() => ({
1889
+ conversationId: "group-error",
1890
+ conversationType: "group",
1891
+ metadataVersion: 1,
1892
+ lastSeenAt: 1,
1893
+ lastRefreshedAt: 1,
1894
+ })),
1895
+ upsertConversationDetails: vi.fn(),
1896
+ deleteConversationCache: vi.fn(),
1897
+ };
1898
+ const transport = new MockTransport();
1899
+ const abortController = new AbortController();
1900
+
1901
+ try {
1902
+ setOpenclawClawlingRuntime(runtime);
1903
+ const run = startOpenclawClawlingGateway({
1904
+ cfg: {},
1905
+ account: baseAccount(),
1906
+ abortSignal: abortController.signal,
1907
+ setStatus: vi.fn(),
1908
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1909
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
1910
+ transport,
1911
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1912
+ });
1913
+
1914
+ await completeHandshake(transport, "challenge-meta-error");
1915
+ transport.emitInbound(JSON.stringify({
1916
+ version: "2",
1917
+ event: "chat.metadata.invalidated",
1918
+ trace_id: "meta-error",
1919
+ emitted_at: Date.now(),
1920
+ chat_id: "group-error",
1921
+ payload: { version: 2 },
1922
+ }));
1923
+ await new Promise((resolve) => setTimeout(resolve, 10));
1924
+
1925
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
1926
+ expect(store.deleteConversationCache).not.toHaveBeenCalled();
1927
+ expect(logs.some((line) => line.includes("metadata refresh failed"))).toBe(true);
1928
+
1929
+ abortController.abort();
1930
+ await run;
1931
+ } finally {
1932
+ fetchMock.mockRestore();
1933
+ }
1934
+ });
1935
+
1936
+ it("refreshes activation and cached conversations after hello-ok without writing tool calls", async () => {
1937
+ const runtime = buildNoDispatchRuntime();
1938
+ const requestedIds: string[] = [];
1939
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
1940
+ const id = String(input).split("/").at(-1)!;
1941
+ requestedIds.push(id);
1942
+ if (id === "cached-fail") {
1943
+ return new Response("oops", { status: 500 });
1944
+ }
1945
+ return jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails(id) } });
1946
+ });
1947
+ const cachedIds = ["cached-1", "activation-1", "cached-fail", ...Array.from({ length: 25 }, (_, i) => `cached-${i + 2}`)];
1948
+ const store = {
1949
+ startConnection: vi.fn(() => 125),
1950
+ markConnectSent: vi.fn(),
1951
+ markConnectionReady: vi.fn(),
1952
+ finishConnection: vi.fn(),
1953
+ getActivationConversation: vi.fn(() => ({
1954
+ conversationId: "activation-1",
1955
+ conversationType: "direct",
1956
+ metadataVersion: null,
1957
+ lastSeenAt: null,
1958
+ lastRefreshedAt: null,
1959
+ })),
1960
+ listCachedConversationIds: vi.fn(() => cachedIds),
1961
+ upsertConversationDetails: vi.fn(),
1962
+ deleteConversationCache: vi.fn(),
1963
+ recordToolCall: vi.fn(),
1964
+ };
1965
+ const transport = new MockTransport();
1966
+ const abortController = new AbortController();
1967
+
1968
+ try {
1969
+ setOpenclawClawlingRuntime(runtime);
1970
+ const run = startOpenclawClawlingGateway({
1971
+ cfg: {},
1972
+ account: baseAccount(),
1973
+ abortSignal: abortController.signal,
1974
+ setStatus: vi.fn(),
1975
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
1976
+ log: { info: vi.fn(), error: vi.fn() },
1977
+ transport,
1978
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
1979
+ });
1980
+
1981
+ await completeHandshake(transport, "challenge-fresh-fetch");
1982
+ await new Promise((resolve) => setTimeout(resolve, 30));
1983
+
1984
+ expect(store.listCachedConversationIds).toHaveBeenCalledWith({
1985
+ platform: "openclaw",
1986
+ accountId: "default",
1987
+ limit: 20,
1988
+ });
1989
+ expect(requestedIds[0]).toBe("activation-1");
1990
+ expect(requestedIds.filter((id) => id === "activation-1")).toHaveLength(1);
1991
+ expect(requestedIds).toContain("cached-1");
1992
+ expect(requestedIds).toContain("cached-fail");
1993
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1994
+ conversationId: "cached-1",
1995
+ }));
1996
+ expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
1997
+ conversationId: "cached-2",
1998
+ }));
1999
+ expect(store.recordToolCall).not.toHaveBeenCalled();
2000
+
2001
+ abortController.abort();
2002
+ await run;
2003
+ } finally {
2004
+ fetchMock.mockRestore();
2005
+ }
2006
+ });
2007
+
2008
+ it("records auth failure and transport error as terminal connection states", async () => {
2009
+ const authTransport = new MockTransport();
2010
+ const authStore = {
2011
+ startConnection: vi.fn(() => 201),
2012
+ markConnectSent: vi.fn(),
2013
+ markConnectionReady: vi.fn(),
2014
+ finishConnection: vi.fn(),
2015
+ };
2016
+
2017
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
2018
+ const authRun = startOpenclawClawlingGateway({
2019
+ cfg: {},
2020
+ account: baseAccount(),
2021
+ abortSignal: new AbortController().signal,
2022
+ setStatus: () => {},
2023
+ getStatus: () => ({ connected: false, configured: true, running: true }),
2024
+ log: { info: vi.fn(), error: vi.fn() },
2025
+ transport: authTransport,
2026
+ store: authStore,
2027
+ });
2028
+
2029
+ await Promise.resolve();
2030
+ authTransport.emitInbound(
2031
+ JSON.stringify({
2032
+ version: "2",
2033
+ event: "connect.challenge",
2034
+ trace_id: "challenge-auth",
2035
+ emitted_at: Date.now(),
2036
+ payload: { nonce: "nonce-1" },
2037
+ }),
2038
+ );
2039
+ const authConnectFrame = authTransport.sent
2040
+ .map((raw) => JSON.parse(raw))
2041
+ .find((env) => env.event === "connect");
2042
+ authTransport.emitInbound(
2043
+ JSON.stringify({
2044
+ version: "2",
2045
+ event: "hello-fail",
2046
+ trace_id: authConnectFrame.trace_id,
2047
+ emitted_at: Date.now(),
2048
+ payload: { reason: "authentication failed" },
2049
+ }),
2050
+ );
2051
+
2052
+ await authRun;
2053
+
2054
+ expect(authStore.finishConnection).toHaveBeenCalledWith(
2055
+ 201,
2056
+ expect.objectContaining({ state: "auth_failed", error: "authentication failed" }),
2057
+ );
2058
+
2059
+ const transport = new MockTransport();
2060
+ const abortController = new AbortController();
2061
+ const transportStore = {
2062
+ startConnection: vi.fn(() => 301),
2063
+ markConnectSent: vi.fn(),
2064
+ markConnectionReady: vi.fn(),
2065
+ finishConnection: vi.fn(),
2066
+ };
2067
+ const transportRun = startOpenclawClawlingGateway({
2068
+ cfg: {},
2069
+ account: baseAccount(),
405
2070
  abortSignal: abortController.signal,
406
2071
  setStatus: () => {},
407
2072
  getStatus: () => ({ connected: false, configured: true, running: true }),
408
- log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
2073
+ log: { info: vi.fn(), error: vi.fn() },
409
2074
  transport,
2075
+ store: transportStore,
410
2076
  });
411
2077
 
412
2078
  await Promise.resolve();
@@ -414,77 +2080,90 @@ describe("openclaw-clawchat runtime helpers", () => {
414
2080
  JSON.stringify({
415
2081
  version: "2",
416
2082
  event: "connect.challenge",
417
- trace_id: "challenge-1",
2083
+ trace_id: "challenge-transport",
418
2084
  emitted_at: Date.now(),
419
2085
  payload: { nonce: "nonce-1" },
420
2086
  }),
421
2087
  );
422
- const connectFrame = transport.sent
2088
+ const transportConnectFrame = transport.sent
423
2089
  .map((raw) => JSON.parse(raw))
424
2090
  .find((env) => env.event === "connect");
425
2091
  transport.emitInbound(
426
2092
  JSON.stringify({
427
2093
  version: "2",
428
2094
  event: "hello-ok",
429
- trace_id: connectFrame.trace_id,
2095
+ trace_id: transportConnectFrame.trace_id,
430
2096
  emitted_at: Date.now(),
431
2097
  payload: {},
432
2098
  }),
433
2099
  );
434
2100
  await Promise.resolve();
435
-
436
- const client = getOpenclawClawlingClient("default")!;
437
- transport.close(1006, "network lost");
2101
+ transport.emitError(new Error("socket down"));
438
2102
  await Promise.resolve();
2103
+ abortController.abort();
2104
+ await transportRun;
439
2105
 
440
- let sendResult: unknown;
441
- const sendPromise = sendOpenclawClawlingText({
442
- client,
443
- account: baseAccount({ ack: { timeout: 15000, autoResendOnTimeout: false } }),
444
- to: { chatId: "chat-1", chatType: "direct" },
445
- text: "queued while reconnecting",
446
- log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
447
- }).then((result) => {
448
- sendResult = result;
449
- return result;
450
- });
451
- await Promise.resolve();
2106
+ expect(transportStore.finishConnection).toHaveBeenCalledWith(
2107
+ 301,
2108
+ expect.objectContaining({ state: "transport_error", error: "socket down" }),
2109
+ );
2110
+ });
452
2111
 
453
- const sentBeforeReady = transport.sent.length;
454
- expect(logs.some((line) => line.includes("event=send_queued"))).toBe(true);
2112
+ it("logs handshake_ok with the connect trace", async () => {
2113
+ const logs: string[] = [];
2114
+ const transport = new MockTransport();
2115
+ const abortController = new AbortController();
455
2116
 
456
- (transport as unknown as { _state: string })._state = "open";
457
- client.emit("state", { from: "reconnecting", to: "connected" });
2117
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
2118
+ const run = startOpenclawClawlingGateway({
2119
+ cfg: {},
2120
+ account: baseAccount(),
2121
+ abortSignal: abortController.signal,
2122
+ setStatus: () => {},
2123
+ getStatus: () => ({ connected: false, configured: true, running: true }),
2124
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
2125
+ transport,
2126
+ });
458
2127
 
459
- expect(logs).toContainEqual(
460
- expect.stringMatching(
461
- /^clawchat\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=1 state=ready action=flush_queue trace_id=[^ ]+ elapsed_ms=\d+ queue_size=1$/,
462
- ),
2128
+ await Promise.resolve();
2129
+ transport.emitInbound(
2130
+ JSON.stringify({
2131
+ version: "2",
2132
+ event: "connect.challenge",
2133
+ trace_id: "challenge-1",
2134
+ emitted_at: Date.now(),
2135
+ payload: { nonce: "nonce-1" },
2136
+ }),
463
2137
  );
464
- expect(transport.sent.length).toBe(sentBeforeReady + 1);
465
- const queuedFrame = JSON.parse(transport.sent.at(-1)!);
466
- expect(queuedFrame.event).toBe("message.send");
467
- expect(logs.some((line) => line.includes("event=send_flush"))).toBe(true);
468
-
2138
+ const connectFrame = transport.sent
2139
+ .map((raw) => JSON.parse(raw))
2140
+ .find((env) => env.event === "connect");
469
2141
  transport.emitInbound(
470
2142
  JSON.stringify({
471
2143
  version: "2",
472
- event: "message.ack",
473
- trace_id: queuedFrame.trace_id,
2144
+ event: "hello-ok",
2145
+ trace_id: connectFrame.trace_id,
474
2146
  emitted_at: Date.now(),
475
- chat_id: "chat-1",
476
- payload: { message_id: "server-1", accepted_at: 1234 },
2147
+ payload: {},
477
2148
  }),
478
2149
  );
479
- await sendPromise;
480
- expect(sendResult).toEqual({ messageId: "server-1", acceptedAt: 1234 });
2150
+ await Promise.resolve();
2151
+
2152
+ expect(logs).toContainEqual(
2153
+ expect.stringMatching(
2154
+ new RegExp(
2155
+ "^clawchat\\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=0 state=ready action=flush_queue trace_id=" +
2156
+ connectFrame.trace_id +
2157
+ " elapsed_ms=\\d+ queue_size=0$",
2158
+ ),
2159
+ ),
2160
+ );
481
2161
 
482
2162
  abortController.abort();
483
2163
  await run;
484
2164
  });
485
2165
 
486
- it("uses real attempt and reconnect_count in websocket logs across reconnect", async () => {
487
- vi.useFakeTimers();
2166
+ it("logs JSON ping and pong as protocol control", async () => {
488
2167
  const logs: string[] = [];
489
2168
  const transport = new MockTransport();
490
2169
  const abortController = new AbortController();
@@ -492,14 +2171,7 @@ describe("openclaw-clawchat runtime helpers", () => {
492
2171
  setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
493
2172
  const run = startOpenclawClawlingGateway({
494
2173
  cfg: {},
495
- account: baseAccount({
496
- reconnect: {
497
- initialDelay: 1000,
498
- maxDelay: 30000,
499
- jitterRatio: 0,
500
- maxRetries: Number.POSITIVE_INFINITY,
501
- },
502
- }),
2174
+ account: baseAccount(),
503
2175
  abortSignal: abortController.signal,
504
2176
  setStatus: () => {},
505
2177
  getStatus: () => ({ connected: false, configured: true, running: true }),
@@ -517,21 +2189,282 @@ describe("openclaw-clawchat runtime helpers", () => {
517
2189
  payload: { nonce: "nonce-1" },
518
2190
  }),
519
2191
  );
520
- const firstConnectFrame = transport.sent
2192
+ const connectFrame = transport.sent
521
2193
  .map((raw) => JSON.parse(raw))
522
2194
  .find((env) => env.event === "connect");
523
2195
  transport.emitInbound(
524
2196
  JSON.stringify({
525
2197
  version: "2",
526
2198
  event: "hello-ok",
527
- trace_id: firstConnectFrame.trace_id,
2199
+ trace_id: connectFrame.trace_id,
528
2200
  emitted_at: Date.now(),
529
2201
  payload: {},
530
2202
  }),
531
2203
  );
532
2204
  await Promise.resolve();
2205
+ transport.sent.length = 0;
533
2206
 
534
- transport.close(1006, "network lost");
2207
+ transport.emitInbound(
2208
+ JSON.stringify({
2209
+ version: "2",
2210
+ event: "ping",
2211
+ trace_id: "trace-ping",
2212
+ emitted_at: Date.now(),
2213
+ payload: {},
2214
+ }),
2215
+ );
2216
+ transport.emitInbound(
2217
+ JSON.stringify({
2218
+ version: "2",
2219
+ event: "pong",
2220
+ trace_id: "trace-pong",
2221
+ emitted_at: Date.now(),
2222
+ payload: {},
2223
+ }),
2224
+ );
2225
+
2226
+ expect(transport.sent.map((raw) => JSON.parse(raw))).toContainEqual(
2227
+ expect.objectContaining({ event: "pong", trace_id: "trace-ping" }),
2228
+ );
2229
+ expect(logs).toContain(
2230
+ "clawchat.ws event=protocol_ping_received account_id=default attempt=1 reconnect_count=0 state=ready action=send_pong trace_id=trace-ping",
2231
+ );
2232
+ expect(logs).toContain(
2233
+ "clawchat.ws event=protocol_pong_received account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-pong",
2234
+ );
2235
+
2236
+ abortController.abort();
2237
+ await run;
2238
+ });
2239
+
2240
+ it("logs unknown ready-state events as inbound_ignored", async () => {
2241
+ const logs: string[] = [];
2242
+ const transport = new MockTransport();
2243
+ const abortController = new AbortController();
2244
+
2245
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
2246
+ const run = startOpenclawClawlingGateway({
2247
+ cfg: {},
2248
+ account: baseAccount(),
2249
+ abortSignal: abortController.signal,
2250
+ setStatus: () => {},
2251
+ getStatus: () => ({ connected: false, configured: true, running: true }),
2252
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
2253
+ transport,
2254
+ });
2255
+
2256
+ await Promise.resolve();
2257
+ transport.emitInbound(
2258
+ JSON.stringify({
2259
+ version: "2",
2260
+ event: "connect.challenge",
2261
+ trace_id: "challenge-1",
2262
+ emitted_at: Date.now(),
2263
+ payload: { nonce: "nonce-1" },
2264
+ }),
2265
+ );
2266
+ const connectFrame = transport.sent
2267
+ .map((raw) => JSON.parse(raw))
2268
+ .find((env) => env.event === "connect");
2269
+ transport.emitInbound(
2270
+ JSON.stringify({
2271
+ version: "2",
2272
+ event: "hello-ok",
2273
+ trace_id: connectFrame.trace_id,
2274
+ emitted_at: Date.now(),
2275
+ payload: {},
2276
+ }),
2277
+ );
2278
+ await Promise.resolve();
2279
+
2280
+ transport.emitInbound(
2281
+ JSON.stringify({
2282
+ version: "2",
2283
+ event: "custom.event",
2284
+ trace_id: "trace-custom",
2285
+ emitted_at: Date.now(),
2286
+ payload: {},
2287
+ }),
2288
+ );
2289
+
2290
+ expect(logs).toContain(
2291
+ "clawchat.ws event=inbound_ignored account_id=default attempt=1 reconnect_count=0 state=ready action=ignore event_name=custom.event trace_id=trace-custom",
2292
+ );
2293
+
2294
+ abortController.abort();
2295
+ await run;
2296
+ });
2297
+
2298
+ it("auto flushes queued outbound when runtime observes connected", async () => {
2299
+ const logs: string[] = [];
2300
+ const transport = new MockTransport();
2301
+ const abortController = new AbortController();
2302
+ const account = baseAccount({
2303
+ ack: { timeout: 15000, autoResendOnTimeout: false },
2304
+ reconnect: {
2305
+ initialDelay: 1,
2306
+ maxDelay: 1,
2307
+ jitterRatio: 0,
2308
+ maxRetries: Number.POSITIVE_INFINITY,
2309
+ },
2310
+ });
2311
+
2312
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
2313
+ const run = startOpenclawClawlingGateway({
2314
+ cfg: {},
2315
+ account,
2316
+ abortSignal: abortController.signal,
2317
+ setStatus: () => {},
2318
+ getStatus: () => ({ connected: false, configured: true, running: true }),
2319
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
2320
+ transport,
2321
+ });
2322
+
2323
+ await Promise.resolve();
2324
+ transport.emitInbound(
2325
+ JSON.stringify({
2326
+ version: "2",
2327
+ event: "connect.challenge",
2328
+ trace_id: "challenge-1",
2329
+ emitted_at: Date.now(),
2330
+ payload: { nonce: "nonce-1" },
2331
+ }),
2332
+ );
2333
+ const connectFrame = transport.sent
2334
+ .map((raw) => JSON.parse(raw))
2335
+ .find((env) => env.event === "connect");
2336
+ transport.emitInbound(
2337
+ JSON.stringify({
2338
+ version: "2",
2339
+ event: "hello-ok",
2340
+ trace_id: connectFrame.trace_id,
2341
+ emitted_at: Date.now(),
2342
+ payload: {},
2343
+ }),
2344
+ );
2345
+ await new Promise((resolve) => setTimeout(resolve, 5));
2346
+
2347
+ const client = getOpenclawClawlingClient("default")!;
2348
+ transport.close(1006, "network lost");
2349
+ await Promise.resolve();
2350
+
2351
+ let sendResult: unknown;
2352
+ const sendPromise = sendOpenclawClawlingText({
2353
+ client,
2354
+ account,
2355
+ to: { chatId: "chat-1", chatType: "direct" },
2356
+ text: "queued while reconnecting",
2357
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
2358
+ }).then((result) => {
2359
+ sendResult = result;
2360
+ return result;
2361
+ });
2362
+ await Promise.resolve();
2363
+
2364
+ const sentBeforeReady = transport.sent.length;
2365
+ expect(logs.some((line) => line.includes("event=send_queued"))).toBe(true);
2366
+
2367
+ await new Promise((resolve) => setTimeout(resolve, 5));
2368
+ transport.emitInbound(
2369
+ JSON.stringify({
2370
+ version: "2",
2371
+ event: "connect.challenge",
2372
+ trace_id: "challenge-2",
2373
+ emitted_at: Date.now(),
2374
+ payload: { nonce: "nonce-2" },
2375
+ }),
2376
+ );
2377
+ const secondConnectFrame = transport.sent
2378
+ .map((raw) => JSON.parse(raw))
2379
+ .filter((env) => env.event === "connect")
2380
+ .at(-1);
2381
+ transport.emitInbound(
2382
+ JSON.stringify({
2383
+ version: "2",
2384
+ event: "hello-ok",
2385
+ trace_id: secondConnectFrame.trace_id,
2386
+ emitted_at: Date.now(),
2387
+ payload: {},
2388
+ }),
2389
+ );
2390
+ await Promise.resolve();
2391
+
2392
+ expect(logs).toContainEqual(
2393
+ expect.stringMatching(
2394
+ /^clawchat\.ws event=handshake_ok account_id=default attempt=2 reconnect_count=1 state=ready action=flush_queue trace_id=[^ ]+ elapsed_ms=\d+ queue_size=1$/,
2395
+ ),
2396
+ );
2397
+ expect(transport.sent.length).toBe(sentBeforeReady + 2);
2398
+ const queuedFrame = JSON.parse(transport.sent.at(-1)!);
2399
+ expect(queuedFrame.event).toBe("message.send");
2400
+ expect(logs.some((line) => line.includes("event=send_flush"))).toBe(true);
2401
+
2402
+ transport.emitInbound(
2403
+ JSON.stringify({
2404
+ version: "2",
2405
+ event: "message.ack",
2406
+ trace_id: queuedFrame.trace_id,
2407
+ emitted_at: Date.now(),
2408
+ chat_id: "chat-1",
2409
+ payload: { message_id: "server-1", accepted_at: 1234 },
2410
+ }),
2411
+ );
2412
+ await sendPromise;
2413
+ expect(sendResult).toEqual({ messageId: "server-1", acceptedAt: 1234 });
2414
+
2415
+ abortController.abort();
2416
+ await run;
2417
+ });
2418
+
2419
+ it("uses real attempt and reconnect_count in websocket logs across reconnect", async () => {
2420
+ vi.useFakeTimers();
2421
+ const logs: string[] = [];
2422
+ const transport = new MockTransport();
2423
+ const abortController = new AbortController();
2424
+
2425
+ setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
2426
+ const run = startOpenclawClawlingGateway({
2427
+ cfg: {},
2428
+ account: baseAccount({
2429
+ reconnect: {
2430
+ initialDelay: 1000,
2431
+ maxDelay: 30000,
2432
+ jitterRatio: 0,
2433
+ maxRetries: Number.POSITIVE_INFINITY,
2434
+ },
2435
+ }),
2436
+ abortSignal: abortController.signal,
2437
+ setStatus: () => {},
2438
+ getStatus: () => ({ connected: false, configured: true, running: true }),
2439
+ log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
2440
+ transport,
2441
+ });
2442
+
2443
+ await Promise.resolve();
2444
+ transport.emitInbound(
2445
+ JSON.stringify({
2446
+ version: "2",
2447
+ event: "connect.challenge",
2448
+ trace_id: "challenge-1",
2449
+ emitted_at: Date.now(),
2450
+ payload: { nonce: "nonce-1" },
2451
+ }),
2452
+ );
2453
+ const firstConnectFrame = transport.sent
2454
+ .map((raw) => JSON.parse(raw))
2455
+ .find((env) => env.event === "connect");
2456
+ transport.emitInbound(
2457
+ JSON.stringify({
2458
+ version: "2",
2459
+ event: "hello-ok",
2460
+ trace_id: firstConnectFrame.trace_id,
2461
+ emitted_at: Date.now(),
2462
+ payload: {},
2463
+ }),
2464
+ );
2465
+ await Promise.resolve();
2466
+
2467
+ transport.close(1006, "network lost");
535
2468
  await Promise.resolve();
536
2469
  expect(logs).toContain(
537
2470
  "clawchat.ws event=connection_lost account_id=default attempt=1 reconnect_count=0 state=ready action=reconnect code=1006 reason=network lost",
@@ -566,52 +2499,1725 @@ describe("openclaw-clawchat runtime helpers", () => {
566
2499
  );
567
2500
  await Promise.resolve();
568
2501
 
569
- transport.emitInbound(
570
- JSON.stringify({
571
- version: "2",
572
- event: "custom.event",
573
- trace_id: "trace-after-reconnect",
574
- emitted_at: Date.now(),
575
- payload: {},
576
- }),
577
- );
578
- expect(logs).toContain(
579
- "clawchat.ws event=inbound_ignored account_id=default attempt=2 reconnect_count=1 state=ready action=ignore event_name=custom.event trace_id=trace-after-reconnect",
580
- );
2502
+ transport.emitInbound(
2503
+ JSON.stringify({
2504
+ version: "2",
2505
+ event: "custom.event",
2506
+ trace_id: "trace-after-reconnect",
2507
+ emitted_at: Date.now(),
2508
+ payload: {},
2509
+ }),
2510
+ );
2511
+ expect(logs).toContain(
2512
+ "clawchat.ws event=inbound_ignored account_id=default attempt=2 reconnect_count=1 state=ready action=ignore event_name=custom.event trace_id=trace-after-reconnect",
2513
+ );
2514
+
2515
+ await vi.advanceTimersByTimeAsync(5000);
2516
+ expect(logs).toContain(
2517
+ "clawchat.ws event=reconnect_backoff_reset account_id=default attempt=2 reconnect_count=0 state=ready action=reset stable_ms=5000",
2518
+ );
2519
+
2520
+ abortController.abort();
2521
+ await run;
2522
+ vi.useRealTimers();
2523
+ });
2524
+ });
2525
+
2526
+ describe("openclaw-clawchat runtime media ingest", () => {
2527
+ it("memory workspace passes active OpenClaw workspaceDir to the turn context", async () => {
2528
+ const memoryRoot = tempMemoryRoot();
2529
+ const fetchMock = mockMetadataFetches();
2530
+ let capturedContextParams:
2531
+ | Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
2532
+ | undefined;
2533
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
2534
+ const runtime = {
2535
+ agent: {
2536
+ resolveAgentWorkspaceDir: vi.fn(() => memoryRoot),
2537
+ },
2538
+ channel: {
2539
+ routing: {
2540
+ resolveAgentRoute: vi.fn(() => ({
2541
+ agentId: "agent-memory",
2542
+ accountId: "default",
2543
+ sessionKey: "session-memory",
2544
+ })),
2545
+ },
2546
+ session: {
2547
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
2548
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
2549
+ },
2550
+ reply: {
2551
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
2552
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
2553
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
2554
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
2555
+ createReplyDispatcherWithTyping: vi.fn(() => ({
2556
+ dispatcher: {},
2557
+ replyOptions: {},
2558
+ markDispatchIdle: vi.fn(),
2559
+ markRunComplete: vi.fn(),
2560
+ })),
2561
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
2562
+ dispatchReplyFromConfig,
2563
+ },
2564
+ turn: {
2565
+ buildContext: vi.fn((params) => {
2566
+ capturedContextParams =
2567
+ params as Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0];
2568
+ return buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
2569
+ }),
2570
+ },
2571
+ media: {
2572
+ fetchRemoteMedia: vi.fn(),
2573
+ saveMediaBuffer: vi.fn(),
2574
+ loadWebMedia: vi.fn(),
2575
+ },
2576
+ },
2577
+ } as unknown as PluginRuntime;
2578
+
2579
+ setOpenclawClawlingRuntime(runtime);
2580
+ const transport = new MockTransport();
2581
+ const abortController = new AbortController();
2582
+ const run = startOpenclawClawlingGateway({
2583
+ cfg: {} as OpenClawConfig,
2584
+ account: baseAccount(),
2585
+ abortSignal: abortController.signal,
2586
+ setStatus: vi.fn(),
2587
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
2588
+ log: { info: vi.fn(), error: vi.fn() },
2589
+ transport,
2590
+ });
2591
+
2592
+ await completeHandshake(transport, "challenge-memory-workspace");
2593
+ transport.emitInbound(JSON.stringify(inboundMessageEnvelope({
2594
+ chatId: "chat-memory",
2595
+ chatType: "direct",
2596
+ messageId: "msg-memory",
2597
+ senderId: "user-memory",
2598
+ text: "remember this",
2599
+ })));
2600
+ await new Promise((resolve) => setTimeout(resolve, 20));
2601
+ abortController.abort();
2602
+ await run;
2603
+
2604
+ fetchMock.mockRestore();
2605
+ expect(runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith({}, "agent-memory");
2606
+ expect(capturedContextParams?.extra).toEqual({
2607
+ memoryRoot,
2608
+ });
2609
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2610
+ });
2611
+
2612
+ it("memory workspace fails visibly on inbound turns when OpenClaw resolver is missing", async () => {
2613
+ const logError = vi.fn();
2614
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
2615
+ const runtime = {
2616
+ channel: {
2617
+ routing: {
2618
+ resolveAgentRoute: vi.fn(() => ({
2619
+ agentId: "agent-memory",
2620
+ accountId: "default",
2621
+ sessionKey: "session-memory",
2622
+ })),
2623
+ },
2624
+ session: {
2625
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
2626
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
2627
+ },
2628
+ reply: {
2629
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
2630
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
2631
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
2632
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
2633
+ createReplyDispatcherWithTyping: vi.fn(() => ({
2634
+ dispatcher: {},
2635
+ replyOptions: {},
2636
+ markDispatchIdle: vi.fn(),
2637
+ markRunComplete: vi.fn(),
2638
+ })),
2639
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
2640
+ dispatchReplyFromConfig,
2641
+ },
2642
+ turn: {
2643
+ buildContext: vi.fn((params) =>
2644
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
2645
+ ),
2646
+ },
2647
+ media: {
2648
+ fetchRemoteMedia: vi.fn(),
2649
+ saveMediaBuffer: vi.fn(),
2650
+ loadWebMedia: vi.fn(),
2651
+ },
2652
+ },
2653
+ } as unknown as PluginRuntime;
2654
+
2655
+ setOpenclawClawlingRuntime(runtime);
2656
+ const transport = new MockTransport();
2657
+ const abortController = new AbortController();
2658
+ const run = startOpenclawClawlingGateway({
2659
+ cfg: {} as OpenClawConfig,
2660
+ account: baseAccount(),
2661
+ abortSignal: abortController.signal,
2662
+ setStatus: vi.fn(),
2663
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
2664
+ log: { info: vi.fn(), error: logError },
2665
+ transport,
2666
+ });
2667
+
2668
+ await completeHandshake(transport, "challenge-memory-missing-resolver");
2669
+ transport.emitInbound(JSON.stringify(inboundMessageEnvelope({
2670
+ chatId: "chat-memory-missing",
2671
+ chatType: "direct",
2672
+ messageId: "msg-memory-missing",
2673
+ senderId: "user-memory",
2674
+ text: "remember this",
2675
+ })));
2676
+ await new Promise((resolve) => setTimeout(resolve, 20));
2677
+ abortController.abort();
2678
+ await run;
2679
+
2680
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
2681
+ expect(logError).toHaveBeenCalledWith(
2682
+ expect.stringContaining(
2683
+ "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved",
2684
+ ),
2685
+ );
2686
+ });
2687
+
2688
+ it("claims complete inbound messages but not streaming created/add fragments", async () => {
2689
+ const runtime = {
2690
+ agent: createTestMemoryAgent(),
2691
+ channel: {
2692
+ routing: {
2693
+ resolveAgentRoute: vi.fn(() => ({
2694
+ agentId: "u",
2695
+ accountId: "default",
2696
+ sessionKey: "s",
2697
+ })),
2698
+ },
2699
+ session: {
2700
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
2701
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
2702
+ },
2703
+ reply: {
2704
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
2705
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
2706
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
2707
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
2708
+ createReplyDispatcherWithTyping: vi.fn(() => ({
2709
+ dispatcher: {},
2710
+ replyOptions: {},
2711
+ markDispatchIdle: vi.fn(),
2712
+ })),
2713
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
2714
+ await opts.run();
2715
+ }),
2716
+ dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
2717
+ },
2718
+ turn: {
2719
+ buildContext: vi.fn((params) =>
2720
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
2721
+ ),
2722
+ },
2723
+ media: {
2724
+ fetchRemoteMedia: vi.fn(),
2725
+ saveMediaBuffer: vi.fn(),
2726
+ loadWebMedia: vi.fn(),
2727
+ },
2728
+ },
2729
+ } as unknown as PluginRuntime;
2730
+ const store = {
2731
+ startConnection: vi.fn(() => 401),
2732
+ markConnectSent: vi.fn(),
2733
+ markConnectionReady: vi.fn(),
2734
+ finishConnection: vi.fn(),
2735
+ claimMessageOnce: vi.fn(() => true),
2736
+ insertMessage: vi.fn(),
2737
+ upsertConversationSummary: vi.fn(),
2738
+ upsertConversationDetails: vi.fn(),
2739
+ };
2740
+ setOpenclawClawlingRuntime(runtime);
2741
+ const transport = new MockTransport();
2742
+ const abortController = new AbortController();
2743
+ const run = startOpenclawClawlingGateway({
2744
+ cfg: {},
2745
+ account: baseAccount(),
2746
+ abortSignal: abortController.signal,
2747
+ setStatus: vi.fn(),
2748
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
2749
+ log: { info: vi.fn(), error: vi.fn() },
2750
+ transport,
2751
+ store,
2752
+ });
2753
+
2754
+ await Promise.resolve();
2755
+ transport.emitInbound(
2756
+ JSON.stringify({
2757
+ version: "2",
2758
+ event: "connect.challenge",
2759
+ trace_id: "challenge-inbound-persist",
2760
+ emitted_at: Date.now(),
2761
+ payload: { nonce: "nonce" },
2762
+ }),
2763
+ );
2764
+ const connectFrame = transport.sent
2765
+ .map((raw) => JSON.parse(raw))
2766
+ .find((env) => env.event === "connect");
2767
+ transport.emitInbound(
2768
+ JSON.stringify({
2769
+ version: "2",
2770
+ event: "hello-ok",
2771
+ trace_id: connectFrame.trace_id,
2772
+ emitted_at: Date.now(),
2773
+ payload: {},
2774
+ }),
2775
+ );
2776
+ await Promise.resolve();
2777
+
2778
+ for (const event of ["message.created", "message.add"]) {
2779
+ transport.emitInbound(
2780
+ JSON.stringify({
2781
+ version: "2",
2782
+ event,
2783
+ trace_id: `trace-${event}`,
2784
+ emitted_at: Date.now(),
2785
+ chat_id: "chat-1",
2786
+ chat_type: "direct",
2787
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
2788
+ payload: { message_id: "stream-fragment", fragments: [{ kind: "text", text: "part" }] },
2789
+ }),
2790
+ );
2791
+ }
2792
+
2793
+ transport.emitInbound(
2794
+ JSON.stringify({
2795
+ version: "2",
2796
+ event: "message.send",
2797
+ trace_id: "trace-inbound-complete",
2798
+ emitted_at: 12345,
2799
+ chat_id: "chat-1",
2800
+ chat_type: "direct",
2801
+ to: { id: "u", type: "direct" },
2802
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
2803
+ payload: {
2804
+ message_id: "m-persist-inbound",
2805
+ message_mode: "normal",
2806
+ message: {
2807
+ body: { fragments: [{ kind: "text", text: "hello persisted" }] },
2808
+ context: { mentions: [], reply: null },
2809
+ streaming: {
2810
+ status: "static",
2811
+ sequence: 0,
2812
+ mutation_policy: "sealed",
2813
+ started_at: null,
2814
+ completed_at: null,
2815
+ },
2816
+ },
2817
+ },
2818
+ }),
2819
+ );
2820
+ await new Promise((resolve) => setTimeout(resolve, 10));
2821
+ abortController.abort();
2822
+ await run;
2823
+
2824
+ expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
2825
+ expect(store.upsertConversationSummary).toHaveBeenCalledTimes(1);
2826
+ expect(store.upsertConversationSummary).toHaveBeenCalledWith(expect.objectContaining({
2827
+ platform: "openclaw",
2828
+ accountId: "default",
2829
+ conversationId: "chat-1",
2830
+ conversationType: "direct",
2831
+ lastSeenAt: 12345,
2832
+ }));
2833
+ expect(store.upsertConversationDetails).not.toHaveBeenCalled();
2834
+ expect(store.claimMessageOnce).toHaveBeenCalledWith({
2835
+ platform: "openclaw",
2836
+ accountId: "default",
2837
+ kind: "message",
2838
+ direction: "inbound",
2839
+ eventType: "message.send",
2840
+ traceId: "trace-inbound-complete",
2841
+ chatId: "chat-1",
2842
+ messageId: "m-persist-inbound",
2843
+ text: "hello persisted",
2844
+ raw: expect.objectContaining({ event: "message.send" }),
2845
+ });
2846
+ expect(store.insertMessage).not.toHaveBeenCalled();
2847
+ });
2848
+
2849
+ it("does not dispatch duplicate inbound messages already claimed in storage", async () => {
2850
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
2851
+ counts: { final: 1, block: 0, tool: 0 },
2852
+ queuedFinal: true,
2853
+ });
2854
+ const claimMessageOnce = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
2855
+ const runtime = {
2856
+ agent: createTestMemoryAgent(),
2857
+ channel: {
2858
+ routing: {
2859
+ resolveAgentRoute: vi.fn(() => ({
2860
+ agentId: "default",
2861
+ accountId: "default",
2862
+ sessionKey: "s",
2863
+ })),
2864
+ },
2865
+ session: {
2866
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
2867
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
2868
+ },
2869
+ reply: {
2870
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
2871
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
2872
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
2873
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
2874
+ createReplyDispatcherWithTyping: vi.fn(() => ({
2875
+ dispatcher: {},
2876
+ replyOptions: {},
2877
+ markDispatchIdle: vi.fn(),
2878
+ markRunComplete: vi.fn(),
2879
+ })),
2880
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
2881
+ dispatchReplyFromConfig,
2882
+ },
2883
+ turn: {
2884
+ buildContext: vi.fn((params) =>
2885
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
2886
+ ),
2887
+ },
2888
+ media: {
2889
+ fetchRemoteMedia: vi.fn(),
2890
+ saveMediaBuffer: vi.fn(),
2891
+ loadWebMedia: vi.fn(),
2892
+ },
2893
+ },
2894
+ } as unknown as PluginRuntime;
2895
+ setOpenclawClawlingRuntime(runtime);
2896
+ const transport = new MockTransport();
2897
+ const abortController = new AbortController();
2898
+
2899
+ const run = startOpenclawClawlingGateway({
2900
+ cfg: {},
2901
+ account: baseAccount(),
2902
+ abortSignal: abortController.signal,
2903
+ setStatus: vi.fn(),
2904
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
2905
+ log: { info: vi.fn(), error: vi.fn() },
2906
+ transport,
2907
+ store: {
2908
+ startConnection: vi.fn(() => 1),
2909
+ markConnectSent: vi.fn(),
2910
+ markConnectionReady: vi.fn(),
2911
+ finishConnection: vi.fn(),
2912
+ claimMessageOnce,
2913
+ },
2914
+ });
2915
+
2916
+ await Promise.resolve();
2917
+ transport.emitInbound(
2918
+ JSON.stringify({
2919
+ version: "2",
2920
+ event: "connect.challenge",
2921
+ trace_id: "challenge",
2922
+ emitted_at: Date.now(),
2923
+ payload: { nonce: "nonce" },
2924
+ }),
2925
+ );
2926
+ const connectFrame = transport.sent
2927
+ .map((raw) => JSON.parse(raw))
2928
+ .find((env) => env.event === "connect");
2929
+ transport.emitInbound(
2930
+ JSON.stringify({
2931
+ version: "2",
2932
+ event: "hello-ok",
2933
+ trace_id: connectFrame.trace_id,
2934
+ emitted_at: Date.now(),
2935
+ payload: {},
2936
+ }),
2937
+ );
2938
+ await Promise.resolve();
2939
+
2940
+ const duplicateFrame = {
2941
+ version: "2",
2942
+ event: "message.send",
2943
+ trace_id: "dup-trace",
2944
+ emitted_at: Date.now(),
2945
+ chat_id: "chat-1",
2946
+ chat_type: "direct",
2947
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
2948
+ payload: {
2949
+ message_id: "duplicate-message",
2950
+ message_mode: "normal",
2951
+ message: {
2952
+ body: { fragments: [{ kind: "text", text: "hello" }] },
2953
+ context: { mentions: [], reply: null },
2954
+ streaming: {
2955
+ status: "static",
2956
+ sequence: 0,
2957
+ mutation_policy: "sealed",
2958
+ started_at: null,
2959
+ completed_at: null,
2960
+ },
2961
+ },
2962
+ },
2963
+ };
2964
+ transport.emitInbound(JSON.stringify(duplicateFrame));
2965
+ transport.emitInbound(JSON.stringify({ ...duplicateFrame, trace_id: "dup-trace-2" }));
2966
+ await new Promise((resolve) => setTimeout(resolve, 20));
2967
+ abortController.abort();
2968
+ await run;
2969
+
2970
+ expect(claimMessageOnce).toHaveBeenCalledTimes(2);
2971
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2972
+ });
2973
+
2974
+ it("dispatches pending activation bootstrap through the normal direct inbound agent path after ready", async () => {
2975
+ const capturedCtxs: Record<string, unknown>[] = [];
2976
+ const resolveAgentRoute = vi.fn(() => ({
2977
+ agentId: "default",
2978
+ accountId: "default",
2979
+ sessionKey: "session-from-route",
2980
+ }));
2981
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
2982
+ counts: { final: 1, block: 0, tool: 0 },
2983
+ queuedFinal: true,
2984
+ });
2985
+ const runtime = {
2986
+ agent: createTestMemoryAgent(),
2987
+ channel: {
2988
+ routing: { resolveAgentRoute },
2989
+ session: {
2990
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
2991
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
2992
+ },
2993
+ reply: {
2994
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
2995
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
2996
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => {
2997
+ capturedCtxs.push(ctx);
2998
+ return ctx;
2999
+ }),
3000
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
3001
+ createReplyDispatcherWithTyping: vi.fn(() => ({
3002
+ dispatcher: {},
3003
+ replyOptions: {},
3004
+ markDispatchIdle: vi.fn(),
3005
+ markRunComplete: vi.fn(),
3006
+ })),
3007
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
3008
+ dispatchReplyFromConfig,
3009
+ },
3010
+ turn: {
3011
+ buildContext: vi.fn((params) => {
3012
+ const ctx = buildTestInboundContext(
3013
+ params as Parameters<typeof buildTestInboundContext>[0],
3014
+ );
3015
+ capturedCtxs.push(ctx);
3016
+ return ctx;
3017
+ }),
3018
+ },
3019
+ media: {
3020
+ fetchRemoteMedia: vi.fn(),
3021
+ saveMediaBuffer: vi.fn(),
3022
+ loadWebMedia: vi.fn(),
3023
+ },
3024
+ },
3025
+ } as unknown as PluginRuntime;
3026
+ const store = {
3027
+ startConnection: vi.fn(() => 501),
3028
+ markConnectSent: vi.fn(),
3029
+ markConnectionReady: vi.fn(),
3030
+ finishConnection: vi.fn(),
3031
+ claimMessageOnce: vi.fn(() => true),
3032
+ claimPendingActivationBootstrap: vi.fn(() => ({ conversationId: "conv-activation" })),
3033
+ markActivationBootstrapSent: vi.fn(() => true),
3034
+ releaseActivationBootstrapClaim: vi.fn(() => true),
3035
+ };
3036
+
3037
+ setOpenclawClawlingRuntime(runtime);
3038
+ const transport = new MockTransport();
3039
+ const abortController = new AbortController();
3040
+ const run = startOpenclawClawlingGateway({
3041
+ cfg: {} as OpenClawConfig,
3042
+ account: baseAccount({ token: "secret-token-value" }),
3043
+ abortSignal: abortController.signal,
3044
+ setStatus: vi.fn(),
3045
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
3046
+ log: { info: vi.fn(), error: vi.fn() },
3047
+ transport,
3048
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
3049
+ });
3050
+
3051
+ await completeHandshake(transport, "challenge-bootstrap-1");
3052
+ await new Promise((resolve) => setTimeout(resolve, 30));
3053
+ abortController.abort();
3054
+ await run;
3055
+
3056
+ expect(store.claimPendingActivationBootstrap).toHaveBeenCalledWith({
3057
+ platform: "openclaw",
3058
+ accountId: "default",
3059
+ });
3060
+ expect(store.claimMessageOnce).toHaveBeenCalledWith(
3061
+ expect.objectContaining({
3062
+ platform: "openclaw",
3063
+ accountId: "default",
3064
+ kind: "message",
3065
+ direction: "inbound",
3066
+ eventType: "message.send",
3067
+ chatId: "conv-activation",
3068
+ messageId: expect.stringContaining("bootstrap"),
3069
+ }),
3070
+ );
3071
+ expect(resolveAgentRoute).toHaveBeenCalledWith(
3072
+ expect.objectContaining({ peer: { kind: "direct", id: "conv-activation" } }),
3073
+ );
3074
+ expect(capturedCtxs).toHaveLength(1);
3075
+ const ctx = capturedCtxs[0]!;
3076
+ const bodyForAgent = String(ctx.BodyForAgent);
3077
+ expect(ctx.From).toBe("openclaw-clawchat:conv-activation");
3078
+ expect(ctx.OriginatingTo).toBe("openclaw-clawchat:conv-activation");
3079
+ expect(ctx.ChatType).toBe("direct");
3080
+ expect(bodyForAgent).toBe(EXPECTED_ACTIVATION_BOOTSTRAP_TEXT);
3081
+ expect(bodyForAgent).not.toContain("secret-token-value");
3082
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3083
+ expect(store.markActivationBootstrapSent).toHaveBeenCalledWith({
3084
+ platform: "openclaw",
3085
+ accountId: "default",
3086
+ conversationId: "conv-activation",
3087
+ });
3088
+ expect(dispatchReplyFromConfig.mock.invocationCallOrder[0]!).toBeLessThan(
3089
+ store.markActivationBootstrapSent.mock.invocationCallOrder[0]!,
3090
+ );
3091
+ });
3092
+
3093
+ it("does not repeat an activation bootstrap across reconnect while the first dispatch is in flight", async () => {
3094
+ let resolveDispatch: (value: unknown) => void = () => {};
3095
+ const dispatchReplyFromConfig = vi.fn(
3096
+ () =>
3097
+ new Promise((resolve) => {
3098
+ resolveDispatch = resolve;
3099
+ }),
3100
+ );
3101
+ const runtime = {
3102
+ agent: createTestMemoryAgent(),
3103
+ channel: {
3104
+ routing: {
3105
+ resolveAgentRoute: vi.fn(() => ({
3106
+ agentId: "default",
3107
+ accountId: "default",
3108
+ sessionKey: "session-from-route",
3109
+ })),
3110
+ },
3111
+ session: {
3112
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
3113
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
3114
+ },
3115
+ reply: {
3116
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
3117
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
3118
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
3119
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
3120
+ createReplyDispatcherWithTyping: vi.fn(() => ({
3121
+ dispatcher: {},
3122
+ replyOptions: {},
3123
+ markDispatchIdle: vi.fn(),
3124
+ markRunComplete: vi.fn(),
3125
+ })),
3126
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
3127
+ dispatchReplyFromConfig,
3128
+ },
3129
+ turn: {
3130
+ buildContext: vi.fn((params) =>
3131
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
3132
+ ),
3133
+ },
3134
+ media: {
3135
+ fetchRemoteMedia: vi.fn(),
3136
+ saveMediaBuffer: vi.fn(),
3137
+ loadWebMedia: vi.fn(),
3138
+ },
3139
+ },
3140
+ } as unknown as PluginRuntime;
3141
+ const store = {
3142
+ startConnection: vi.fn(() => 601),
3143
+ markConnectSent: vi.fn(),
3144
+ markConnectionReady: vi.fn(),
3145
+ finishConnection: vi.fn(),
3146
+ claimMessageOnce: vi.fn(() => true),
3147
+ claimPendingActivationBootstrap: vi
3148
+ .fn()
3149
+ .mockReturnValueOnce({ conversationId: "conv-activation" })
3150
+ .mockReturnValue(null),
3151
+ markActivationBootstrapSent: vi.fn(() => true),
3152
+ releaseActivationBootstrapClaim: vi.fn(() => true),
3153
+ };
3154
+ setOpenclawClawlingRuntime(runtime);
3155
+ const transport = new MockTransport();
3156
+ const abortController = new AbortController();
3157
+ const run = startOpenclawClawlingGateway({
3158
+ cfg: {} as OpenClawConfig,
3159
+ account: baseAccount({
3160
+ reconnect: {
3161
+ initialDelay: 1,
3162
+ maxDelay: 1,
3163
+ jitterRatio: 0,
3164
+ maxRetries: Number.POSITIVE_INFINITY,
3165
+ },
3166
+ }),
3167
+ abortSignal: abortController.signal,
3168
+ setStatus: vi.fn(),
3169
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
3170
+ log: { info: vi.fn(), error: vi.fn() },
3171
+ transport,
3172
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
3173
+ });
3174
+
3175
+ await completeHandshake(transport, "challenge-bootstrap-first");
3176
+ await new Promise((resolve) => setTimeout(resolve, 10));
3177
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3178
+
3179
+ transport.close(1006, "network lost");
3180
+ await new Promise((resolve) => setTimeout(resolve, 10));
3181
+ await completeHandshake(transport, "challenge-bootstrap-reconnect");
3182
+ await new Promise((resolve) => setTimeout(resolve, 10));
3183
+ expect(store.claimPendingActivationBootstrap).toHaveBeenCalledTimes(2);
3184
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3185
+
3186
+ resolveDispatch({ counts: { final: 1, block: 0, tool: 0 }, queuedFinal: true });
3187
+ await new Promise((resolve) => setTimeout(resolve, 10));
3188
+ abortController.abort();
3189
+ await run;
3190
+
3191
+ expect(store.markActivationBootstrapSent).toHaveBeenCalledTimes(1);
3192
+ expect(store.markActivationBootstrapSent).toHaveBeenCalledWith({
3193
+ platform: "openclaw",
3194
+ accountId: "default",
3195
+ conversationId: "conv-activation",
3196
+ });
3197
+ });
3198
+
3199
+ it("releases an activation bootstrap claim when agent submission fails", async () => {
3200
+ const dispatchReplyFromConfig = vi.fn().mockRejectedValue(new Error("dispatch failed"));
3201
+ const runtime = {
3202
+ agent: createTestMemoryAgent(),
3203
+ channel: {
3204
+ routing: {
3205
+ resolveAgentRoute: vi.fn(() => ({
3206
+ agentId: "default",
3207
+ accountId: "default",
3208
+ sessionKey: "session-from-route",
3209
+ })),
3210
+ },
3211
+ session: {
3212
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
3213
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
3214
+ },
3215
+ reply: {
3216
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
3217
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
3218
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
3219
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
3220
+ createReplyDispatcherWithTyping: vi.fn(() => ({
3221
+ dispatcher: {},
3222
+ replyOptions: {},
3223
+ markDispatchIdle: vi.fn(),
3224
+ markRunComplete: vi.fn(),
3225
+ })),
3226
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
3227
+ dispatchReplyFromConfig,
3228
+ },
3229
+ turn: {
3230
+ buildContext: vi.fn((params) =>
3231
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
3232
+ ),
3233
+ },
3234
+ media: {
3235
+ fetchRemoteMedia: vi.fn(),
3236
+ saveMediaBuffer: vi.fn(),
3237
+ loadWebMedia: vi.fn(),
3238
+ },
3239
+ },
3240
+ } as unknown as PluginRuntime;
3241
+ const store = {
3242
+ startConnection: vi.fn(() => 701),
3243
+ markConnectSent: vi.fn(),
3244
+ markConnectionReady: vi.fn(),
3245
+ finishConnection: vi.fn(),
3246
+ claimMessageOnce: vi.fn(() => true),
3247
+ claimPendingActivationBootstrap: vi.fn(() => ({ conversationId: "conv-activation" })),
3248
+ markActivationBootstrapSent: vi.fn(() => true),
3249
+ releaseActivationBootstrapClaim: vi.fn(() => true),
3250
+ };
3251
+ setOpenclawClawlingRuntime(runtime);
3252
+ const transport = new MockTransport();
3253
+ const abortController = new AbortController();
3254
+ const run = startOpenclawClawlingGateway({
3255
+ cfg: {} as OpenClawConfig,
3256
+ account: baseAccount(),
3257
+ abortSignal: abortController.signal,
3258
+ setStatus: vi.fn(),
3259
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
3260
+ log: { info: vi.fn(), error: vi.fn() },
3261
+ transport,
3262
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
3263
+ });
3264
+
3265
+ await completeHandshake(transport, "challenge-bootstrap-failure");
3266
+ await new Promise((resolve) => setTimeout(resolve, 30));
3267
+ abortController.abort();
3268
+ await run;
3269
+
3270
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3271
+ expect(store.markActivationBootstrapSent).not.toHaveBeenCalled();
3272
+ expect(store.releaseActivationBootstrapClaim).toHaveBeenCalledWith({
3273
+ platform: "openclaw",
3274
+ accountId: "default",
3275
+ conversationId: "conv-activation",
3276
+ });
3277
+ });
3278
+
3279
+ it("fetches inbound media via runtime.channel.media and populates MediaPath/MediaPaths", async () => {
3280
+ const fetched: Array<{ url: string }> = [];
3281
+ const saved: Array<{ ct: string | undefined }> = [];
3282
+ let capturedCtx: Record<string, unknown> | undefined;
3283
+ const buildContext = vi.fn((params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) => {
3284
+ capturedCtx = buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
3285
+ return capturedCtx as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>;
3286
+ });
3287
+ const resolveAgentRoute = vi.fn(() => ({
3288
+ agentId: "u",
3289
+ accountId: "default",
3290
+ sessionKey: "s",
3291
+ }));
3292
+ const handlers = new Map<string, Function>();
3293
+ registerClawChatPromptInjection({
3294
+ on: vi.fn((name: string, handler: Function) => handlers.set(name, handler)),
3295
+ });
3296
+ let promptBuildResult: unknown;
3297
+ const cfg = {
3298
+ session: { store: "/tmp/sessions.json", dmScope: "main" },
3299
+ } as unknown as OpenClawConfig;
3300
+ const memoryRoot = tempMemoryRoot();
3301
+ await writeClawChatMetadata(memoryRoot, { targetType: "owner", targetId: "owner" }, {
3302
+ agent_id: "u",
3303
+ owner_id: "owner-u",
3304
+ nickname: "Hermes",
3305
+ behavior: "Always be Hermes.",
3306
+ });
3307
+ await writeClawChatMetadata(memoryRoot, { targetType: "user", targetId: "user-1" }, {
3308
+ id: "user-1",
3309
+ nickname: "User",
3310
+ avatar_url: "https://example.test/user.png",
3311
+ bio: "Profile bio",
3312
+ profile_type: "agent",
3313
+ });
3314
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
3315
+ if (String(input) === "https://api.example.com/v1/users/user-1") {
3316
+ return jsonEnvelope({
3317
+ code: 0,
3318
+ msg: "ok",
3319
+ data: {
3320
+ id: "user-1",
3321
+ type: "agent",
3322
+ nickname: "User",
3323
+ avatar_url: "https://example.test/user.png",
3324
+ bio: "Profile bio",
3325
+ },
3326
+ });
3327
+ }
3328
+ throw new Error(`unexpected fetch ${String(input)}`);
3329
+ });
3330
+ const store = {
3331
+ startConnection: vi.fn(() => 201),
3332
+ markConnectSent: vi.fn(),
3333
+ markConnectionReady: vi.fn(),
3334
+ finishConnection: vi.fn(),
3335
+ getCachedConversation: vi.fn(() => ({ conversationId: "chat-1" })),
3336
+ upsertConversationSummary: vi.fn(),
3337
+ };
3338
+
3339
+ const runtime = {
3340
+ agent: createTestMemoryAgent(memoryRoot),
3341
+ channel: {
3342
+ routing: {
3343
+ resolveAgentRoute,
3344
+ },
3345
+ session: {
3346
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
3347
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
3348
+ },
3349
+ reply: {
3350
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
3351
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
3352
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
3353
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
3354
+ createReplyDispatcherWithTyping: vi.fn(() => ({
3355
+ dispatcher: {},
3356
+ replyOptions: {},
3357
+ markDispatchIdle: vi.fn(),
3358
+ markRunComplete: vi.fn(),
3359
+ })),
3360
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
3361
+ await opts.run();
3362
+ }),
3363
+ dispatchReplyFromConfig: vi.fn(async () => {
3364
+ promptBuildResult = await handlers.get("before_prompt_build")?.({}, { sessionKey: "s" });
3365
+ }),
3366
+ },
3367
+ turn: {
3368
+ buildContext,
3369
+ },
3370
+ media: {
3371
+ fetchRemoteMedia: vi.fn(async ({ url }: { url: string }) => {
3372
+ fetched.push({ url });
3373
+ return { buffer: Buffer.from("x"), contentType: "image/png", fileName: "f.png" };
3374
+ }),
3375
+ saveMediaBuffer: vi.fn(async (_buf, ct?: string) => {
3376
+ saved.push({ ct });
3377
+ return { path: `/cache/${saved.length}.png`, contentType: "image/png" };
3378
+ }),
3379
+ loadWebMedia: vi.fn(),
3380
+ },
3381
+ },
3382
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
3383
+
3384
+ setOpenclawClawlingRuntime(runtime);
3385
+
3386
+ const { startOpenclawClawlingGateway } = await import("./runtime.ts");
3387
+ const { MockTransport } = await import("./mock-transport.ts");
3388
+ const transport = new MockTransport();
3389
+ const abortController = new AbortController();
3390
+
3391
+ const startPromise = startOpenclawClawlingGateway({
3392
+ cfg,
3393
+ account: {
3394
+ accountId: "default",
3395
+ name: "openclaw-clawchat",
3396
+ enabled: true,
3397
+ configured: true,
3398
+ websocketUrl: "ws://t",
3399
+ baseUrl: "https://api.example.com",
3400
+ token: "tk",
3401
+ userId: "u",
3402
+ ownerUserId: "owner-u",
3403
+ replyMode: "static",
3404
+ groupMode: "all",
3405
+ groupCommandMode: "owner",
3406
+ groups: {},
3407
+ forwardThinking: true,
3408
+ forwardToolCalls: false,
3409
+ richInteractions: false,
3410
+ allowFrom: [],
3411
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
3412
+ reconnect: {
3413
+ initialDelay: 1000,
3414
+ maxDelay: 30000,
3415
+ jitterRatio: 0.3,
3416
+ maxRetries: Number.POSITIVE_INFINITY,
3417
+ },
3418
+ heartbeat: { interval: 25000, timeout: 10000 },
3419
+ ack: { timeout: 10000, autoResendOnTimeout: false },
3420
+ },
3421
+ abortSignal: abortController.signal,
3422
+ setStatus: vi.fn(),
3423
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3424
+ log: { info: () => {}, error: () => {} },
3425
+ transport,
3426
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
3427
+ });
3428
+
3429
+ await new Promise((r) => setTimeout(r, 0));
3430
+ transport.emitInbound(
3431
+ JSON.stringify({
3432
+ version: "2",
3433
+ event: "connect.challenge",
3434
+ trace_id: "tc",
3435
+ emitted_at: Date.now(),
3436
+ payload: { nonce: "n" },
3437
+ }),
3438
+ );
3439
+ const connectFrame = transport.sent
3440
+ .map((raw) => JSON.parse(raw))
3441
+ .find((env) => env.event === "connect");
3442
+ transport.emitInbound(
3443
+ JSON.stringify({
3444
+ version: "2",
3445
+ event: "hello-ok",
3446
+ trace_id: connectFrame.trace_id,
3447
+ emitted_at: Date.now(),
3448
+ payload: {},
3449
+ }),
3450
+ );
3451
+ await new Promise((r) => setTimeout(r, 5));
3452
+
3453
+ transport.emitInbound(
3454
+ JSON.stringify({
3455
+ version: "2",
3456
+ event: "message.send",
3457
+ trace_id: "ti",
3458
+ emitted_at: Date.now(),
3459
+ chat_id: "chat-1",
3460
+ chat_type: "direct",
3461
+ to: { id: "u", type: "direct" },
3462
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
3463
+ payload: {
3464
+ message_id: "m-with-image",
3465
+ message_mode: "normal",
3466
+ message: {
3467
+ body: {
3468
+ fragments: [
3469
+ { kind: "text", text: "see this:" },
3470
+ { kind: "image", url: "https://cdn/a.png", mime: "image/png" },
3471
+ ],
3472
+ },
3473
+ context: { mentions: [], reply: null },
3474
+ streaming: {
3475
+ status: "static",
3476
+ sequence: 0,
3477
+ mutation_policy: "sealed",
3478
+ started_at: null,
3479
+ completed_at: null,
3480
+ },
3481
+ },
3482
+ },
3483
+ }),
3484
+ );
3485
+ await new Promise((r) => setTimeout(r, 30));
3486
+ abortController.abort();
3487
+ await startPromise;
3488
+
3489
+ expect(fetched).toEqual([{ url: "https://cdn/a.png" }]);
3490
+ expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
3491
+ expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
3492
+ expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
3493
+ expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:chat-1");
3494
+ expect(capturedCtx?.ConversationLabel).toBe("chat-1");
3495
+ expect(capturedCtx?.GroupSystemPrompt).toBeUndefined();
3496
+ const directBuildContextArg = buildContext.mock.calls[0]?.[0] as
3497
+ | Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
3498
+ | undefined;
3499
+ expect(directBuildContextArg?.conversation.kind).toBe("direct");
3500
+ expect(directBuildContextArg?.supplemental).toBeUndefined();
3501
+ const directPrompt = (promptBuildResult as { appendSystemContext?: string }).appendSystemContext;
3502
+ expect(directPrompt).toContain("## Current ClawChat Owner Metadata");
3503
+ expect(directPrompt).toContain("behavior: Always be Hermes.");
3504
+ expect(directPrompt).toContain("## Current ClawChat User Metadata");
3505
+ expect(directPrompt).toContain("\nprofile_type: agent");
3506
+ expect(directPrompt).toContain("nickname: User");
3507
+ expect(directPrompt).toContain("avatar_url: https://example.test/user.png");
3508
+ expect(directPrompt).toContain("bio: Profile bio");
3509
+ expect(directPrompt).toContain("## Current ClawChat Message Metadata");
3510
+ expect(directPrompt).toContain("chat_type: dm");
3511
+ expect(directPrompt).toContain("sender_id: user-1");
3512
+ expect(directPrompt).toContain("sender_profile_type: agent");
3513
+ expect(directPrompt).toContain("sender_is_owner: false");
3514
+ expect(directPrompt).not.toContain("sender_relation:");
3515
+ expect(directPrompt).not.toContain("peer_id:");
3516
+ expect(directPrompt).not.toContain("group_id:");
3517
+ expect(fetchSpy).toHaveBeenCalledWith(
3518
+ "https://api.example.com/v1/users/user-1",
3519
+ expect.objectContaining({ method: "GET" }),
3520
+ );
3521
+ expect(fetchSpy).not.toHaveBeenCalledWith("https://cdn/a.png", expect.anything());
3522
+ expect(renderClawChatPromptInjectionForSession("s")).toBeUndefined();
3523
+ expect(capturedCtx?.SenderId).toBe("user-1");
3524
+ expect(resolveAgentRoute).toHaveBeenCalledWith(
3525
+ expect.objectContaining({
3526
+ cfg: expect.objectContaining({
3527
+ session: expect.objectContaining({
3528
+ dmScope: "per-account-channel-peer",
3529
+ store: "/tmp/sessions.json",
3530
+ }),
3531
+ }),
3532
+ peer: { kind: "direct", id: "chat-1" },
3533
+ }),
3534
+ );
3535
+ expect(cfg.session?.dmScope).toBe("main");
3536
+ fetchSpy.mockRestore();
3537
+ });
3538
+
3539
+ it("uses group chat_id as the canonical conversation identity", async () => {
3540
+ vi.useFakeTimers();
3541
+ let capturedCtx: Record<string, unknown> | undefined;
3542
+ const buildContext = vi.fn((params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) => {
3543
+ capturedCtx = buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
3544
+ return capturedCtx as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>;
3545
+ });
3546
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
3547
+ counts: { final: 1, block: 0, tool: 0 },
3548
+ queuedFinal: true,
3549
+ });
3550
+ stageClawChatPromptInjection({ sessionKey: "s", prompt: "stale direct prompt" });
3551
+ const store = {
3552
+ startConnection: vi.fn(() => 202),
3553
+ markConnectSent: vi.fn(),
3554
+ markConnectionReady: vi.fn(),
3555
+ finishConnection: vi.fn(),
3556
+ getCachedConversation: vi.fn(() => ({ conversationId: "grp-1" })),
3557
+ upsertConversationSummary: vi.fn(),
3558
+ };
3559
+
3560
+ const runtime = {
3561
+ agent: createTestMemoryAgent(),
3562
+ channel: {
3563
+ routing: {
3564
+ resolveAgentRoute: vi.fn(() => ({
3565
+ agentId: "u",
3566
+ accountId: "default",
3567
+ sessionKey: "s",
3568
+ })),
3569
+ },
3570
+ session: {
3571
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
3572
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
3573
+ },
3574
+ reply: {
3575
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
3576
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
3577
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
3578
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
3579
+ createReplyDispatcherWithTyping: vi.fn(() => ({
3580
+ dispatcher: {},
3581
+ replyOptions: {},
3582
+ markDispatchIdle: vi.fn(),
3583
+ markRunComplete: vi.fn(),
3584
+ })),
3585
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
3586
+ await opts.run();
3587
+ }),
3588
+ dispatchReplyFromConfig,
3589
+ },
3590
+ turn: {
3591
+ buildContext,
3592
+ },
3593
+ media: {
3594
+ fetchRemoteMedia: vi.fn(),
3595
+ saveMediaBuffer: vi.fn(),
3596
+ loadWebMedia: vi.fn(),
3597
+ },
3598
+ },
3599
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
3600
+
3601
+ setOpenclawClawlingRuntime(runtime);
3602
+
3603
+ const { startOpenclawClawlingGateway } = await import("./runtime.ts");
3604
+ const transport = new MockTransport();
3605
+ const abortController = new AbortController();
3606
+
3607
+ try {
3608
+ const startPromise = startOpenclawClawlingGateway({
3609
+ cfg: {} as import("openclaw/plugin-sdk/core").OpenClawConfig,
3610
+ account: {
3611
+ accountId: "default",
3612
+ name: "openclaw-clawchat",
3613
+ enabled: true,
3614
+ configured: true,
3615
+ websocketUrl: "ws://t",
3616
+ baseUrl: "https://api.example.com",
3617
+ token: "tk",
3618
+ userId: "u",
3619
+ ownerUserId: "owner-u",
3620
+ replyMode: "static",
3621
+ groupMode: "all",
3622
+ groupCommandMode: "owner",
3623
+ groups: {},
3624
+ forwardThinking: true,
3625
+ forwardToolCalls: false,
3626
+ richInteractions: false,
3627
+ allowFrom: [],
3628
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
3629
+ reconnect: {
3630
+ initialDelay: 1000,
3631
+ maxDelay: 30000,
3632
+ jitterRatio: 0.3,
3633
+ maxRetries: Number.POSITIVE_INFINITY,
3634
+ },
3635
+ heartbeat: { interval: 25000, timeout: 10000 },
3636
+ ack: { timeout: 10000, autoResendOnTimeout: false },
3637
+ },
3638
+ abortSignal: abortController.signal,
3639
+ setStatus: vi.fn(),
3640
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3641
+ log: { info: () => {}, error: () => {} },
3642
+ transport,
3643
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
3644
+ });
3645
+
3646
+ await Promise.resolve();
3647
+ transport.emitInbound(
3648
+ JSON.stringify({
3649
+ version: "2",
3650
+ event: "connect.challenge",
3651
+ trace_id: "tc",
3652
+ emitted_at: Date.now(),
3653
+ payload: { nonce: "n" },
3654
+ }),
3655
+ );
3656
+ const connectFrame = transport.sent
3657
+ .map((raw) => JSON.parse(raw))
3658
+ .find((env) => env.event === "connect");
3659
+ transport.emitInbound(
3660
+ JSON.stringify({
3661
+ version: "2",
3662
+ event: "hello-ok",
3663
+ trace_id: connectFrame.trace_id,
3664
+ emitted_at: Date.now(),
3665
+ payload: {},
3666
+ }),
3667
+ );
3668
+ await vi.advanceTimersByTimeAsync(5);
3669
+
3670
+ transport.emitInbound(
3671
+ JSON.stringify({
3672
+ version: "2",
3673
+ event: "message.send",
3674
+ trace_id: "tg",
3675
+ emitted_at: Date.now(),
3676
+ chat_id: "grp-1",
3677
+ chat_type: "group",
3678
+ to: { id: "u", type: "group" },
3679
+ sender: { id: "user-1", type: "direct", nick_name: "Alice" },
3680
+ payload: {
3681
+ message_id: "m-group",
3682
+ message_mode: "normal",
3683
+ message: {
3684
+ body: {
3685
+ fragments: [{ kind: "text", text: "hello group" }],
3686
+ },
3687
+ context: { mentions: [], reply: null },
3688
+ streaming: {
3689
+ status: "static",
3690
+ sequence: 0,
3691
+ mutation_policy: "sealed",
3692
+ started_at: null,
3693
+ completed_at: null,
3694
+ },
3695
+ },
3696
+ },
3697
+ }),
3698
+ );
3699
+ await Promise.resolve();
3700
+ await vi.advanceTimersByTimeAsync(10000);
3701
+ await vi.waitFor(() => expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1"));
3702
+ abortController.abort();
3703
+ await startPromise;
3704
+ } finally {
3705
+ vi.useRealTimers();
3706
+ }
3707
+
3708
+ expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
3709
+ expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:group:grp-1");
3710
+ expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
3711
+ expect(capturedCtx?.SenderId).toBe("user-1");
3712
+ expect(capturedCtx?.ChatType).toBe("group");
3713
+ expect(capturedCtx?.GroupSystemPrompt).toContain("## Current ClawChat Message Metadata");
3714
+ expect(capturedCtx?.GroupSystemPrompt).toContain("chat_type: group");
3715
+ expect(capturedCtx?.GroupSystemPrompt).not.toContain("## ClawChat Group Profile/Regulation");
3716
+ expect(capturedCtx?.GroupSystemPrompt).not.toContain("profile_id: grp-1");
3717
+ expect(capturedCtx?.GroupSystemPrompt).toContain("## Current ClawChat Message Metadata");
3718
+ expect(capturedCtx?.GroupSystemPrompt).not.toContain("## Current ClawChat Group Batch");
3719
+ expect(capturedCtx?.GroupSystemPrompt).toContain("chat_type: group");
3720
+ expect(capturedCtx?.GroupSystemPrompt).toContain("group_id: grp-1");
3721
+ expect(capturedCtx?.GroupSystemPrompt).not.toContain("was_mentioned:");
3722
+ expect(capturedCtx?.GroupSystemPrompt).not.toContain("mentioned_user_ids:");
3723
+ expect(capturedCtx?.GroupSystemPrompt).not.toContain("sender_id: user-1");
3724
+ const groupBuildContextArg = buildContext.mock.calls[0]?.[0] as
3725
+ | Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
3726
+ | undefined;
3727
+ expect(groupBuildContextArg?.conversation.kind).toBe("group");
3728
+ expect(groupBuildContextArg?.supplemental?.groupSystemPrompt).toContain("## Current ClawChat Message Metadata");
3729
+ expect(renderClawChatPromptInjectionForSession("s")).toBeUndefined();
3730
+ expect(dispatchReplyFromConfig).toHaveBeenCalledWith(
3731
+ expect.objectContaining({
3732
+ replyOptions: expect.objectContaining({
3733
+ sourceReplyDeliveryMode: "automatic",
3734
+ }),
3735
+ }),
3736
+ );
3737
+ });
3738
+
3739
+ it("coalesces eligible group messages after ten seconds of inactivity", async () => {
3740
+ vi.useFakeTimers();
3741
+ try {
3742
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
3743
+ counts: { final: 1, block: 0, tool: 0 },
3744
+ queuedFinal: true,
3745
+ });
3746
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
3747
+ setOpenclawClawlingRuntime(runtime);
3748
+ const transport = new MockTransport();
3749
+ const abortController = new AbortController();
3750
+ const startPromise = startOpenclawClawlingGateway({
3751
+ cfg: {} as OpenClawConfig,
3752
+ account: baseAccount(),
3753
+ abortSignal: abortController.signal,
3754
+ setStatus: vi.fn(),
3755
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3756
+ log: { info: vi.fn(), error: vi.fn() },
3757
+ transport,
3758
+ });
3759
+
3760
+ await completeHandshake(transport);
3761
+ transport.emitInbound(
3762
+ JSON.stringify(
3763
+ inboundMessageEnvelope({
3764
+ chatId: "room-1",
3765
+ chatType: "group",
3766
+ messageId: "msg-1",
3767
+ senderId: "u1",
3768
+ text: "first",
3769
+ emittedAt: 1000,
3770
+ }),
3771
+ ),
3772
+ );
3773
+ transport.emitInbound(
3774
+ JSON.stringify(
3775
+ inboundMessageEnvelope({
3776
+ chatId: "room-1",
3777
+ chatType: "group",
3778
+ messageId: "msg-2",
3779
+ senderId: "u2",
3780
+ senderType: "agent",
3781
+ text: "second",
3782
+ emittedAt: 2000,
3783
+ }),
3784
+ ),
3785
+ );
3786
+ await Promise.resolve();
3787
+
3788
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
3789
+ await vi.advanceTimersByTimeAsync(9999);
3790
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
3791
+
3792
+ await vi.advanceTimersByTimeAsync(1);
3793
+
3794
+ await vi.waitFor(() => expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1));
3795
+ const ctx = dispatchReplyFromConfig.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
3796
+ expect(ctx.RawBody).toContain("ClawChat group batch (2 messages, 10s idle, 30s max)");
3797
+ expect(ctx.RawBody).toContain("[message]\nsender_id: u1\nsender_name: user-1\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\ntext:\nfirst");
3798
+ expect(ctx.RawBody).toContain("[message]\nsender_id: u2\nsender_name: user-2\nsender_profile_type: agent\nsender_is_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\ntext:\nsecond");
3799
+ expect(ctx.RawBody).not.toContain("sender_relation");
3800
+ expect(ctx.RawBody).not.toContain("[msg-");
3801
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("## Current ClawChat Message Metadata"));
3802
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("group_id: room-1"));
3803
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("response_decision: Decide whether this group input needs a reply from you."));
3804
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("allowed_outputs: normal_reply OR exact_empty_response"));
3805
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining('exact_empty_response: ""'));
3806
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining('no_reply_protocol: If you choose not to reply, return exactly "" and nothing else.'));
3807
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("silent_response:"));
3808
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("sender_id: u"));
3809
+
3810
+ abortController.abort();
3811
+ await startPromise;
3812
+ } finally {
3813
+ vi.useRealTimers();
3814
+ }
3815
+ });
3816
+
3817
+ it("dispatches owner group slash commands without group batching", async () => {
3818
+ vi.useFakeTimers();
3819
+ try {
3820
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
3821
+ counts: { final: 1, block: 0, tool: 0 },
3822
+ queuedFinal: true,
3823
+ });
3824
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
3825
+ setOpenclawClawlingRuntime(runtime);
3826
+ const transport = new MockTransport();
3827
+ const abortController = new AbortController();
3828
+ const startPromise = startOpenclawClawlingGateway({
3829
+ cfg: {} as OpenClawConfig,
3830
+ account: baseAccount({ groupCommandMode: "owner", ownerUserId: "owner-u" }),
3831
+ abortSignal: abortController.signal,
3832
+ setStatus: vi.fn(),
3833
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3834
+ log: { info: vi.fn(), error: vi.fn() },
3835
+ transport,
3836
+ });
3837
+
3838
+ await completeHandshake(transport);
3839
+ transport.emitInbound(
3840
+ JSON.stringify(
3841
+ inboundMessageEnvelope({
3842
+ chatId: "room-1",
3843
+ chatType: "group",
3844
+ messageId: "cmd-owner",
3845
+ senderId: "owner-u",
3846
+ text: "/reset",
3847
+ }),
3848
+ ),
3849
+ );
3850
+
3851
+ await vi.waitFor(() => {
3852
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3853
+ });
3854
+ const ctx = dispatchReplyFromConfig.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
3855
+ expect(ctx.RawBody).toBe("/reset");
3856
+ expect(ctx.CommandBody).toBe("/reset");
3857
+ expect(ctx.RawBody).not.toContain("ClawChat group batch");
3858
+
3859
+ await vi.advanceTimersByTimeAsync(10000);
3860
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3861
+
3862
+ abortController.abort();
3863
+ await startPromise;
3864
+ } finally {
3865
+ vi.useRealTimers();
3866
+ }
3867
+ });
3868
+
3869
+ it("drops non-owner group slash commands in owner command mode", async () => {
3870
+ vi.useFakeTimers();
3871
+ try {
3872
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
3873
+ counts: { final: 1, block: 0, tool: 0 },
3874
+ queuedFinal: true,
3875
+ });
3876
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
3877
+ setOpenclawClawlingRuntime(runtime);
3878
+ const transport = new MockTransport();
3879
+ const abortController = new AbortController();
3880
+ const startPromise = startOpenclawClawlingGateway({
3881
+ cfg: {} as OpenClawConfig,
3882
+ account: baseAccount({ groupCommandMode: "owner", ownerUserId: "owner-u" }),
3883
+ abortSignal: abortController.signal,
3884
+ setStatus: vi.fn(),
3885
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3886
+ log: { info: vi.fn(), error: vi.fn() },
3887
+ transport,
3888
+ });
3889
+
3890
+ await completeHandshake(transport);
3891
+ transport.emitInbound(
3892
+ JSON.stringify(
3893
+ inboundMessageEnvelope({
3894
+ chatId: "room-1",
3895
+ chatType: "group",
3896
+ messageId: "cmd-non-owner",
3897
+ senderId: "user-1",
3898
+ text: "/reset",
3899
+ }),
3900
+ ),
3901
+ );
3902
+ await Promise.resolve();
3903
+ await vi.advanceTimersByTimeAsync(10000);
3904
+
3905
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
3906
+
3907
+ abortController.abort();
3908
+ await startPromise;
3909
+ } finally {
3910
+ vi.useRealTimers();
3911
+ }
3912
+ });
3913
+
3914
+ it("uses envelope sender identity in coalesced group transcripts when memory is not injected", async () => {
3915
+ vi.useFakeTimers();
3916
+ try {
3917
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
3918
+ counts: { final: 1, block: 0, tool: 0 },
3919
+ queuedFinal: true,
3920
+ });
3921
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
3922
+ const store = {
3923
+ startConnection: vi.fn(() => 901),
3924
+ markConnectSent: vi.fn(),
3925
+ markConnectionReady: vi.fn(),
3926
+ finishConnection: vi.fn(),
3927
+ getCachedConversation: vi.fn(() => ({ conversationId: "room-1" })),
3928
+ upsertConversationSummary: vi.fn(),
3929
+ upsertConversationDetails: vi.fn(),
3930
+ };
3931
+ setOpenclawClawlingRuntime(runtime);
3932
+ const transport = new MockTransport();
3933
+ const abortController = new AbortController();
3934
+ const startPromise = startOpenclawClawlingGateway({
3935
+ cfg: {} as OpenClawConfig,
3936
+ account: baseAccount(),
3937
+ abortSignal: abortController.signal,
3938
+ setStatus: vi.fn(),
3939
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3940
+ log: { info: vi.fn(), error: vi.fn() },
3941
+ transport,
3942
+ store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
3943
+ });
3944
+
3945
+ await completeHandshake(transport);
3946
+ transport.emitInbound(
3947
+ JSON.stringify(
3948
+ inboundMessageEnvelope({
3949
+ chatId: "room-1",
3950
+ chatType: "group",
3951
+ messageId: "msg-1",
3952
+ senderId: "usr_colin",
3953
+ text: "first",
3954
+ emittedAt: 1000,
3955
+ }),
3956
+ ),
3957
+ );
3958
+ await Promise.resolve();
3959
+
3960
+ await vi.advanceTimersByTimeAsync(10000);
3961
+
3962
+ await vi.waitFor(() => expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1));
3963
+ const ctx = dispatchReplyFromConfig.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
3964
+ expect(ctx.RawBody).toContain("[message]\nsender_id: usr_colin\nsender_name: usr_colin\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\ntext:\nfirst");
3965
+ expect(ctx.RawBody).not.toContain("sender_name: ColinShen");
3966
+
3967
+ abortController.abort();
3968
+ await startPromise;
3969
+ } finally {
3970
+ vi.useRealTimers();
3971
+ }
3972
+ });
3973
+
3974
+ it("dispatches group messages that mention the configured account immediately", async () => {
3975
+ vi.useFakeTimers();
3976
+ try {
3977
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
3978
+ counts: { final: 1, block: 0, tool: 0 },
3979
+ queuedFinal: true,
3980
+ });
3981
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
3982
+ setOpenclawClawlingRuntime(runtime);
3983
+ const transport = new MockTransport();
3984
+ const abortController = new AbortController();
3985
+ const startPromise = startOpenclawClawlingGateway({
3986
+ cfg: {} as OpenClawConfig,
3987
+ account: baseAccount({ groupMode: "all", userId: "u" }),
3988
+ abortSignal: abortController.signal,
3989
+ setStatus: vi.fn(),
3990
+ getStatus: vi.fn(() => ({ accountId: "default" })),
3991
+ log: { info: vi.fn(), error: vi.fn() },
3992
+ transport,
3993
+ });
3994
+
3995
+ await completeHandshake(transport);
3996
+ transport.emitInbound(
3997
+ JSON.stringify(
3998
+ inboundMessageEnvelope({
3999
+ chatId: "room-1",
4000
+ chatType: "group",
4001
+ messageId: "msg-mention-self",
4002
+ senderId: "u1",
4003
+ text: "urgent",
4004
+ mentions: ["u"],
4005
+ }),
4006
+ ),
4007
+ );
4008
+
4009
+ await vi.advanceTimersByTimeAsync(1);
4010
+ await vi.waitFor(() => expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1));
4011
+ await vi.advanceTimersByTimeAsync(9999);
4012
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
4013
+ const ctx = dispatchReplyFromConfig.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
4014
+ expect(ctx.RawBody).toContain("ClawChat group batch (1 message, 10s idle, 30s max)");
4015
+ expect(ctx.RawBody).toContain("[message]\nsender_id: u1\nsender_name: user-1\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: true\nmentioned_user_ids: u\ntext:\nurgent");
4016
+ expect(ctx.WasMentioned).toBe(true);
4017
+ expect(ctx.MentionedUserIds).toEqual(["u"]);
4018
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("## Current ClawChat Message Metadata"));
4019
+
4020
+ abortController.abort();
4021
+ await startPromise;
4022
+ } finally {
4023
+ vi.useRealTimers();
4024
+ }
4025
+ });
4026
+
4027
+ it("flushes pending group batch immediately when a later message mentions the configured account", async () => {
4028
+ vi.useFakeTimers();
4029
+ try {
4030
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
4031
+ counts: { final: 1, block: 0, tool: 0 },
4032
+ queuedFinal: true,
4033
+ });
4034
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
4035
+ setOpenclawClawlingRuntime(runtime);
4036
+ const transport = new MockTransport();
4037
+ const abortController = new AbortController();
4038
+ const startPromise = startOpenclawClawlingGateway({
4039
+ cfg: {} as OpenClawConfig,
4040
+ account: baseAccount({ groupMode: "all", userId: "u" }),
4041
+ abortSignal: abortController.signal,
4042
+ setStatus: vi.fn(),
4043
+ getStatus: vi.fn(() => ({ accountId: "default" })),
4044
+ log: { info: vi.fn(), error: vi.fn() },
4045
+ transport,
4046
+ });
4047
+
4048
+ await completeHandshake(transport);
4049
+ transport.emitInbound(
4050
+ JSON.stringify(
4051
+ inboundMessageEnvelope({
4052
+ chatId: "room-1",
4053
+ chatType: "group",
4054
+ messageId: "msg-quiet",
4055
+ senderId: "u1",
4056
+ text: "context",
4057
+ emittedAt: 1000,
4058
+ }),
4059
+ ),
4060
+ );
4061
+ await vi.advanceTimersByTimeAsync(1000);
4062
+ transport.emitInbound(
4063
+ JSON.stringify(
4064
+ inboundMessageEnvelope({
4065
+ chatId: "room-1",
4066
+ chatType: "group",
4067
+ messageId: "msg-mention-self",
4068
+ senderId: "u2",
4069
+ text: "urgent",
4070
+ mentions: ["u"],
4071
+ emittedAt: 2000,
4072
+ }),
4073
+ ),
4074
+ );
4075
+
4076
+ await vi.advanceTimersByTimeAsync(1);
4077
+ await vi.waitFor(() => expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1));
4078
+ const ctx = dispatchReplyFromConfig.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
4079
+ expect(ctx.RawBody).toContain("ClawChat group batch (2 messages, 10s idle, 30s max)");
4080
+ expect(ctx.RawBody).toContain("[message]\nsender_id: u1\nsender_name: user-1\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\ntext:\ncontext");
4081
+ expect(ctx.RawBody).toContain("[message]\nsender_id: u2\nsender_name: user-2\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: true\nmentioned_user_ids: u\ntext:\nurgent");
4082
+ expect(ctx.RawBody).not.toContain("[msg-");
4083
+ expect(ctx.WasMentioned).toBe(true);
4084
+ expect(ctx.MentionedUserIds).toEqual(["u"]);
4085
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("## Current ClawChat Message Metadata"));
4086
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("was_mentioned:"));
4087
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("mentioned_user_ids:"));
4088
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("response_decision: Decide whether this group input needs a reply from you."));
4089
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("allowed_outputs: normal_reply OR exact_empty_response"));
4090
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining('exact_empty_response: ""'));
4091
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("silent_response:"));
4092
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("\nempty_response:"));
4093
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("sender_id: u2"));
4094
+
4095
+ await vi.advanceTimersByTimeAsync(30000);
4096
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
4097
+
4098
+ abortController.abort();
4099
+ await startPromise;
4100
+ } finally {
4101
+ vi.useRealTimers();
4102
+ }
4103
+ });
581
4104
 
582
- await vi.advanceTimersByTimeAsync(5000);
583
- expect(logs).toContain(
584
- "clawchat.ws event=reconnect_backoff_reset account_id=default attempt=2 reconnect_count=0 state=ready action=reset stable_ms=5000",
585
- );
4105
+ it("exposes mentioned user ids for groupMode all messages that mention others", async () => {
4106
+ vi.useFakeTimers();
4107
+ try {
4108
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
4109
+ counts: { final: 1, block: 0, tool: 0 },
4110
+ queuedFinal: true,
4111
+ });
4112
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
4113
+ setOpenclawClawlingRuntime(runtime);
4114
+ const transport = new MockTransport();
4115
+ const abortController = new AbortController();
4116
+ const startPromise = startOpenclawClawlingGateway({
4117
+ cfg: {} as OpenClawConfig,
4118
+ account: baseAccount({ groupMode: "all", userId: "u" }),
4119
+ abortSignal: abortController.signal,
4120
+ setStatus: vi.fn(),
4121
+ getStatus: vi.fn(() => ({ accountId: "default" })),
4122
+ log: { info: vi.fn(), error: vi.fn() },
4123
+ transport,
4124
+ });
586
4125
 
587
- abortController.abort();
588
- await run;
589
- vi.useRealTimers();
4126
+ await completeHandshake(transport);
4127
+ transport.emitInbound(
4128
+ JSON.stringify(
4129
+ inboundMessageEnvelope({
4130
+ chatId: "room-1",
4131
+ chatType: "group",
4132
+ messageId: "msg-mention-other",
4133
+ senderId: "u1",
4134
+ text: "heads up",
4135
+ mentions: ["other-user"],
4136
+ }),
4137
+ ),
4138
+ );
4139
+
4140
+ await vi.advanceTimersByTimeAsync(9999);
4141
+ expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
4142
+ await vi.advanceTimersByTimeAsync(1);
4143
+
4144
+ await vi.waitFor(() => expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1));
4145
+ const ctx = dispatchReplyFromConfig.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
4146
+ expect(ctx.WasMentioned).toBe(false);
4147
+ expect(ctx.MentionedUserIds).toEqual(["other-user"]);
4148
+ expect(ctx.GroupSystemPrompt).toEqual(expect.stringContaining("## Current ClawChat Message Metadata"));
4149
+ expect(ctx.GroupSystemPrompt).not.toEqual(expect.stringContaining("mentioned_user_ids: other-user"));
4150
+ expect(ctx.RawBody).toContain("mentions_current_agent: false\nmentioned_user_ids: other-user");
4151
+
4152
+ abortController.abort();
4153
+ await startPromise;
4154
+ } finally {
4155
+ vi.useRealTimers();
4156
+ }
590
4157
  });
591
- });
592
4158
 
593
- describe("openclaw-clawchat runtime media ingest", () => {
594
- it("fetches inbound media via runtime.channel.media and populates MediaPath/MediaPaths", async () => {
595
- const fetched: Array<{ url: string }> = [];
596
- const saved: Array<{ ct: string | undefined }> = [];
597
- let capturedCtx: Record<string, unknown> | undefined;
598
- const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
599
- capturedCtx = ctx;
600
- return ctx;
4159
+ it("does not coalesce direct messages or change direct prompt injection", async () => {
4160
+ const handlers = new Map<string, Function>();
4161
+ registerClawChatPromptInjection({
4162
+ on: vi.fn((name: string, handler: Function) => handlers.set(name, handler)),
601
4163
  });
602
- const resolveAgentRoute = vi.fn(() => ({
603
- agentId: "u",
604
- accountId: "default",
605
- sessionKey: "s",
606
- }));
607
- const cfg = {
608
- session: { store: "/tmp/sessions.json", dmScope: "main" },
609
- } as unknown as OpenClawConfig;
4164
+ let promptBuildResult: unknown;
4165
+ const dispatchReplyFromConfig = vi.fn(async () => {
4166
+ promptBuildResult = await handlers.get("before_prompt_build")?.({}, {
4167
+ sessionKey: "session-from-route",
4168
+ });
4169
+ return { counts: { final: 1, block: 0, tool: 0 }, queuedFinal: true };
4170
+ });
4171
+ const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
4172
+ setOpenclawClawlingRuntime(runtime);
4173
+ const transport = new MockTransport();
4174
+ const abortController = new AbortController();
4175
+ const startPromise = startOpenclawClawlingGateway({
4176
+ cfg: {} as OpenClawConfig,
4177
+ account: baseAccount(),
4178
+ abortSignal: abortController.signal,
4179
+ setStatus: vi.fn(),
4180
+ getStatus: vi.fn(() => ({ accountId: "default" })),
4181
+ log: { info: vi.fn(), error: vi.fn() },
4182
+ transport,
4183
+ });
4184
+
4185
+ await completeHandshake(transport);
4186
+ transport.emitInbound(
4187
+ JSON.stringify(
4188
+ inboundMessageEnvelope({
4189
+ chatId: "chat-1",
4190
+ chatType: "direct",
4191
+ messageId: "dm-1",
4192
+ senderId: "u1",
4193
+ text: "hello",
4194
+ }),
4195
+ ),
4196
+ );
4197
+ await new Promise((resolve) => setTimeout(resolve, 20));
4198
+ abortController.abort();
4199
+ await startPromise;
4200
+
4201
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
4202
+ expect(promptBuildResult).toEqual({
4203
+ appendSystemContext: expect.stringContaining("## Current ClawChat Message Metadata"),
4204
+ });
4205
+ });
610
4206
 
4207
+ it("dispatches completed message.done frames to the OpenClaw agent path", async () => {
4208
+ const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
4209
+ counts: { final: 1, block: 0, tool: 0 },
4210
+ queuedFinal: true,
4211
+ });
611
4212
  const runtime = {
4213
+ agent: createTestMemoryAgent(),
612
4214
  channel: {
613
4215
  routing: {
614
- resolveAgentRoute,
4216
+ resolveAgentRoute: vi.fn(() => ({
4217
+ agentId: "default",
4218
+ accountId: "default",
4219
+ sessionKey: "s",
4220
+ })),
615
4221
  },
616
4222
  session: {
617
4223
  resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
@@ -620,7 +4226,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
620
4226
  reply: {
621
4227
  formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
622
4228
  resolveEnvelopeFormatOptions: vi.fn(() => ({})),
623
- finalizeInboundContext,
4229
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
624
4230
  resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
625
4231
  createReplyDispatcherWithTyping: vi.fn(() => ({
626
4232
  dispatcher: {},
@@ -628,150 +4234,102 @@ describe("openclaw-clawchat runtime media ingest", () => {
628
4234
  markDispatchIdle: vi.fn(),
629
4235
  markRunComplete: vi.fn(),
630
4236
  })),
631
- withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
632
- await opts.run();
633
- }),
634
- dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
4237
+ withReplyDispatcher: vi.fn(
4238
+ async (opts: { run: () => Promise<unknown>; onSettled?: () => void | Promise<void> }) => {
4239
+ try {
4240
+ return await opts.run();
4241
+ } finally {
4242
+ await opts.onSettled?.();
4243
+ }
4244
+ },
4245
+ ),
4246
+ dispatchReplyFromConfig,
4247
+ },
4248
+ turn: {
4249
+ buildContext: vi.fn((params) =>
4250
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
4251
+ ),
635
4252
  },
636
4253
  media: {
637
- fetchRemoteMedia: vi.fn(async ({ url }: { url: string }) => {
638
- fetched.push({ url });
639
- return { buffer: Buffer.from("x"), contentType: "image/png", fileName: "f.png" };
640
- }),
641
- saveMediaBuffer: vi.fn(async (_buf, ct?: string) => {
642
- saved.push({ ct });
643
- return { path: `/cache/${saved.length}.png`, contentType: "image/png" };
644
- }),
4254
+ fetchRemoteMedia: vi.fn(),
4255
+ saveMediaBuffer: vi.fn(),
645
4256
  loadWebMedia: vi.fn(),
646
4257
  },
647
4258
  },
648
- } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
649
-
4259
+ } as unknown as PluginRuntime;
650
4260
  setOpenclawClawlingRuntime(runtime);
651
-
652
- const { startOpenclawClawlingGateway } = await import("./runtime.ts");
653
- const { MockTransport } = await import("@newbase-clawchat/sdk");
654
4261
  const transport = new MockTransport();
655
4262
  const abortController = new AbortController();
656
4263
 
657
- const startPromise = startOpenclawClawlingGateway({
658
- cfg,
659
- account: {
660
- accountId: "default",
661
- name: "openclaw-clawchat",
662
- enabled: true,
663
- configured: true,
664
- websocketUrl: "ws://t",
665
- baseUrl: "https://api.example.com",
666
- token: "tk",
667
- userId: "u",
668
- replyMode: "static",
669
- forwardThinking: true,
670
- forwardToolCalls: false,
671
- allowFrom: [],
672
- stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
673
- reconnect: {
674
- initialDelay: 1000,
675
- maxDelay: 30000,
676
- jitterRatio: 0.3,
677
- maxRetries: Number.POSITIVE_INFINITY,
678
- },
679
- heartbeat: { interval: 25000, timeout: 10000 },
680
- ack: { timeout: 10000, autoResendOnTimeout: false },
681
- },
4264
+ const run = startOpenclawClawlingGateway({
4265
+ cfg: {},
4266
+ account: baseAccount(),
682
4267
  abortSignal: abortController.signal,
683
4268
  setStatus: vi.fn(),
684
- getStatus: vi.fn(() => ({ accountId: "default" })),
685
- log: { info: () => {}, error: () => {} },
4269
+ getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
4270
+ log: { info: vi.fn(), error: vi.fn() },
686
4271
  transport,
687
4272
  });
688
4273
 
689
- await new Promise((r) => setTimeout(r, 0));
4274
+ await Promise.resolve();
690
4275
  transport.emitInbound(
691
4276
  JSON.stringify({
692
4277
  version: "2",
693
4278
  event: "connect.challenge",
694
- trace_id: "tc",
4279
+ trace_id: "challenge",
695
4280
  emitted_at: Date.now(),
696
- payload: { nonce: "n" },
4281
+ payload: { nonce: "nonce" },
697
4282
  }),
698
4283
  );
4284
+ const connectFrame = transport.sent
4285
+ .map((raw) => JSON.parse(raw))
4286
+ .find((env) => env.event === "connect");
699
4287
  transport.emitInbound(
700
4288
  JSON.stringify({
701
4289
  version: "2",
702
4290
  event: "hello-ok",
703
- trace_id: "th",
4291
+ trace_id: connectFrame.trace_id,
704
4292
  emitted_at: Date.now(),
705
4293
  payload: {},
706
4294
  }),
707
4295
  );
708
- await new Promise((r) => setTimeout(r, 5));
709
-
4296
+ await Promise.resolve();
710
4297
  transport.emitInbound(
711
4298
  JSON.stringify({
712
4299
  version: "2",
713
- event: "message.send",
714
- trace_id: "ti",
4300
+ event: "message.done",
4301
+ trace_id: "done-1",
715
4302
  emitted_at: Date.now(),
716
4303
  chat_id: "chat-1",
717
4304
  chat_type: "direct",
718
- to: { id: "u", type: "direct" },
719
4305
  sender: { id: "user-1", type: "direct", nick_name: "User" },
720
4306
  payload: {
721
- message_id: "m-with-image",
722
- message_mode: "normal",
723
- message: {
724
- body: {
725
- fragments: [
726
- { kind: "text", text: "see this:" },
727
- { kind: "image", url: "https://cdn/a.png", mime: "image/png" },
728
- ],
729
- },
730
- context: { mentions: [], reply: null },
731
- streaming: {
732
- status: "static",
733
- sequence: 0,
734
- mutation_policy: "sealed",
735
- started_at: null,
736
- completed_at: null,
737
- },
4307
+ message_id: "stream-1",
4308
+ fragments: [{ kind: "text", text: "completed stream" }],
4309
+ streaming: {
4310
+ status: "done",
4311
+ sequence: 1,
4312
+ mutation_policy: "append_text_only",
4313
+ started_at: null,
4314
+ completed_at: Date.now(),
738
4315
  },
739
4316
  },
740
4317
  }),
741
4318
  );
742
- await new Promise((r) => setTimeout(r, 30));
4319
+ await new Promise((resolve) => setTimeout(resolve, 10));
743
4320
  abortController.abort();
744
- await startPromise;
4321
+ await run;
745
4322
 
746
- expect(fetched).toEqual([{ url: "https://cdn/a.png" }]);
747
- expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
748
- expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
749
- expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
750
- expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:chat-1");
751
- expect(capturedCtx?.ConversationLabel).toBe("chat-1");
752
- expect(capturedCtx?.SenderId).toBe("user-1");
753
- expect(resolveAgentRoute).toHaveBeenCalledWith(
754
- expect.objectContaining({
755
- cfg: expect.objectContaining({
756
- session: expect.objectContaining({
757
- dmScope: "per-account-channel-peer",
758
- store: "/tmp/sessions.json",
759
- }),
760
- }),
761
- peer: { kind: "direct", id: "chat-1" },
762
- }),
763
- );
764
- expect(cfg.session?.dmScope).toBe("main");
4323
+ expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
4324
+ expect(renderClawChatPromptInjectionForSession("s")).toBeUndefined();
765
4325
  });
4326
+ });
766
4327
 
767
- it("uses group chat_id as the canonical conversation identity", async () => {
768
- let capturedCtx: Record<string, unknown> | undefined;
769
- const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
770
- capturedCtx = ctx;
771
- return ctx;
772
- });
773
-
4328
+ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
4329
+ it("clears staged direct prompt when dispatcher setup fails before dispatch", async () => {
4330
+ const logError = vi.fn();
774
4331
  const runtime = {
4332
+ agent: createTestMemoryAgent(),
775
4333
  channel: {
776
4334
  routing: {
777
4335
  resolveAgentRoute: vi.fn(() => ({
@@ -787,18 +4345,18 @@ describe("openclaw-clawchat runtime media ingest", () => {
787
4345
  reply: {
788
4346
  formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
789
4347
  resolveEnvelopeFormatOptions: vi.fn(() => ({})),
790
- finalizeInboundContext,
4348
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
791
4349
  resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
792
- createReplyDispatcherWithTyping: vi.fn(() => ({
793
- dispatcher: {},
794
- replyOptions: {},
795
- markDispatchIdle: vi.fn(),
796
- markRunComplete: vi.fn(),
797
- })),
798
- withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
799
- await opts.run();
4350
+ createReplyDispatcherWithTyping: vi.fn(() => {
4351
+ throw new Error("dispatcher setup failed");
800
4352
  }),
801
- dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
4353
+ withReplyDispatcher: vi.fn(),
4354
+ dispatchReplyFromConfig: vi.fn(),
4355
+ },
4356
+ turn: {
4357
+ buildContext: vi.fn((params) =>
4358
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
4359
+ ),
802
4360
  },
803
4361
  media: {
804
4362
  fetchRemoteMedia: vi.fn(),
@@ -806,44 +4364,19 @@ describe("openclaw-clawchat runtime media ingest", () => {
806
4364
  loadWebMedia: vi.fn(),
807
4365
  },
808
4366
  },
809
- } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
4367
+ } as unknown as PluginRuntime;
810
4368
 
811
4369
  setOpenclawClawlingRuntime(runtime);
812
4370
 
813
- const { startOpenclawClawlingGateway } = await import("./runtime.ts");
814
4371
  const transport = new MockTransport();
815
4372
  const abortController = new AbortController();
816
-
817
4373
  const startPromise = startOpenclawClawlingGateway({
818
- cfg: {} as import("openclaw/plugin-sdk/core").OpenClawConfig,
819
- account: {
820
- accountId: "default",
821
- name: "openclaw-clawchat",
822
- enabled: true,
823
- configured: true,
824
- websocketUrl: "ws://t",
825
- baseUrl: "https://api.example.com",
826
- token: "tk",
827
- userId: "u",
828
- replyMode: "static",
829
- groupMode: "all",
830
- forwardThinking: true,
831
- forwardToolCalls: false,
832
- allowFrom: [],
833
- stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
834
- reconnect: {
835
- initialDelay: 1000,
836
- maxDelay: 30000,
837
- jitterRatio: 0.3,
838
- maxRetries: Number.POSITIVE_INFINITY,
839
- },
840
- heartbeat: { interval: 25000, timeout: 10000 },
841
- ack: { timeout: 10000, autoResendOnTimeout: false },
842
- },
4374
+ cfg: {} as OpenClawConfig,
4375
+ account: baseAccount(),
843
4376
  abortSignal: abortController.signal,
844
4377
  setStatus: vi.fn(),
845
4378
  getStatus: vi.fn(() => ({ accountId: "default" })),
846
- log: { info: () => {}, error: () => {} },
4379
+ log: { info: vi.fn(), error: logError },
847
4380
  transport,
848
4381
  });
849
4382
 
@@ -857,11 +4390,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
857
4390
  payload: { nonce: "n" },
858
4391
  }),
859
4392
  );
4393
+ const connectFrame = transport.sent
4394
+ .map((raw) => JSON.parse(raw))
4395
+ .find((env) => env.event === "connect");
860
4396
  transport.emitInbound(
861
4397
  JSON.stringify({
862
4398
  version: "2",
863
4399
  event: "hello-ok",
864
- trace_id: "th",
4400
+ trace_id: connectFrame.trace_id,
865
4401
  emitted_at: Date.now(),
866
4402
  payload: {},
867
4403
  }),
@@ -872,18 +4408,18 @@ describe("openclaw-clawchat runtime media ingest", () => {
872
4408
  JSON.stringify({
873
4409
  version: "2",
874
4410
  event: "message.send",
875
- trace_id: "tg",
4411
+ trace_id: "tm",
876
4412
  emitted_at: Date.now(),
877
- chat_id: "grp-1",
878
- chat_type: "group",
879
- to: { id: "u", type: "group" },
880
- sender: { id: "user-1", type: "direct", nick_name: "Alice" },
4413
+ chat_id: "chat-1",
4414
+ chat_type: "direct",
4415
+ to: { id: "u", type: "direct" },
4416
+ sender: { id: "user-1", type: "direct", nick_name: "User" },
881
4417
  payload: {
882
- message_id: "m-group",
4418
+ message_id: "m-setup-fail",
883
4419
  message_mode: "normal",
884
4420
  message: {
885
4421
  body: {
886
- fragments: [{ kind: "text", text: "hello group" }],
4422
+ fragments: [{ kind: "text", text: "hello" }],
887
4423
  },
888
4424
  context: { mentions: [], reply: null },
889
4425
  streaming: {
@@ -897,19 +4433,18 @@ describe("openclaw-clawchat runtime media ingest", () => {
897
4433
  },
898
4434
  }),
899
4435
  );
4436
+
900
4437
  await new Promise((r) => setTimeout(r, 30));
901
4438
  abortController.abort();
902
4439
  await startPromise;
903
4440
 
904
- expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
905
- expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:group:grp-1");
906
- expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
907
- expect(capturedCtx?.SenderId).toBe("user-1");
908
- expect(capturedCtx?.ChatType).toBe("group");
4441
+ expect(runtime.channel.reply.dispatchReplyFromConfig).not.toHaveBeenCalled();
4442
+ expect(renderClawChatPromptInjectionForSession("s")).toBeUndefined();
4443
+ expect(logError).toHaveBeenCalledWith(
4444
+ expect.stringContaining("openclaw-clawchat message handler error"),
4445
+ );
909
4446
  });
910
- });
911
4447
 
912
- describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
913
4448
  it("marks dispatch idle when reply dispatch fails", async () => {
914
4449
  const markDispatchIdle = vi.fn();
915
4450
  const withReplyDispatcher = vi.fn(
@@ -925,6 +4460,7 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
925
4460
  const logError = vi.fn();
926
4461
 
927
4462
  const runtime = {
4463
+ agent: createTestMemoryAgent(),
928
4464
  channel: {
929
4465
  routing: {
930
4466
  resolveAgentRoute: vi.fn(() => ({
@@ -950,6 +4486,11 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
950
4486
  withReplyDispatcher,
951
4487
  dispatchReplyFromConfig,
952
4488
  },
4489
+ turn: {
4490
+ buildContext: vi.fn((params) =>
4491
+ buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
4492
+ ),
4493
+ },
953
4494
  media: {
954
4495
  fetchRemoteMedia: vi.fn(),
955
4496
  saveMediaBuffer: vi.fn(),
@@ -1006,11 +4547,14 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
1006
4547
  payload: { nonce: "n" },
1007
4548
  }),
1008
4549
  );
4550
+ const connectFrame = transport.sent
4551
+ .map((raw) => JSON.parse(raw))
4552
+ .find((env) => env.event === "connect");
1009
4553
  transport.emitInbound(
1010
4554
  JSON.stringify({
1011
4555
  version: "2",
1012
4556
  event: "hello-ok",
1013
- trace_id: "th",
4557
+ trace_id: connectFrame.trace_id,
1014
4558
  emitted_at: Date.now(),
1015
4559
  payload: {},
1016
4560
  }),
@@ -1115,11 +4659,14 @@ describe("openclaw-clawchat runtime connect flow", () => {
1115
4659
  payload: { nonce: "n1" },
1116
4660
  }),
1117
4661
  );
4662
+ const connectFrame = transport.sent
4663
+ .map((raw) => JSON.parse(raw))
4664
+ .find((env) => env.event === "connect");
1118
4665
  transport.emitInbound(
1119
4666
  JSON.stringify({
1120
4667
  version: "2",
1121
4668
  event: "hello-ok",
1122
- trace_id: "t2",
4669
+ trace_id: connectFrame.trace_id,
1123
4670
  emitted_at: Date.now(),
1124
4671
  payload: {},
1125
4672
  }),