@clawling/clawchat-plugin-openclaw 2026.5.12-28

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