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