@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +64 -0
- package/README.md +121 -19
- package/dist/index.js +10 -19
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +78 -10
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +25 -156
- package/dist/src/channel.setup.js +120 -0
- package/dist/src/client.js +37 -41
- package/dist/src/config.js +75 -17
- package/dist/src/inbound.js +79 -61
- package/dist/src/login.runtime.js +84 -19
- package/dist/src/media-runtime.js +8 -8
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +410 -26
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -7
- package/dist/src/reply-dispatcher.js +157 -54
- package/dist/src/runtime.js +795 -119
- package/dist/src/storage.js +689 -0
- package/dist/src/tools-schema.js +98 -16
- package/dist/src/tools.js +422 -135
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +10 -22
- package/openclaw.plugin.json +37 -2
- package/package.json +17 -4
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +88 -0
- package/src/api-client.test.ts +274 -14
- package/src/api-client.ts +138 -23
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +90 -4
- package/src/buffered-stream.test.ts +14 -12
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +269 -60
- package/src/channel.setup.ts +146 -0
- package/src/channel.test.ts +130 -24
- package/src/channel.ts +30 -186
- package/src/client.test.ts +197 -11
- package/src/client.ts +50 -57
- package/src/config.test.ts +108 -6
- package/src/config.ts +95 -24
- package/src/inbound.test.ts +288 -37
- package/src/inbound.ts +96 -84
- package/src/login.runtime.test.ts +347 -13
- package/src/login.runtime.ts +105 -23
- package/src/manifest.test.ts +146 -74
- package/src/media-runtime.test.ts +57 -2
- package/src/media-runtime.ts +26 -17
- 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 +694 -73
- package/src/outbound.ts +484 -31
- package/src/plugin-entry.test.ts +1 -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.test.ts +1 -6
- package/src/protocol.ts +2 -7
- package/src/reply-dispatcher.test.ts +819 -119
- package/src/reply-dispatcher.ts +202 -60
- package/src/runtime.test.ts +2120 -41
- package/src/runtime.ts +935 -142
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +793 -0
- package/src/storage.ts +1095 -0
- package/src/streaming.test.ts +9 -8
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +148 -20
- package/src/tools.test.ts +377 -50
- package/src/tools.ts +574 -154
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
- package/skills/clawchat-account-tools/SKILL.md +0 -26
- package/skills/clawchat-activate/SKILL.md +0 -47
package/src/runtime.test.ts
CHANGED
|
@@ -1,15 +1,220 @@
|
|
|
1
|
-
import { MockTransport, AuthError } from "@newbase-clawchat/sdk";
|
|
2
1
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { MockTransport } from "./mock-transport.ts";
|
|
4
|
+
import { AuthError } from "./protocol-types.ts";
|
|
4
5
|
import {
|
|
5
6
|
classifyClawlingClientError,
|
|
6
7
|
mapClawlingStateToStatus,
|
|
7
8
|
setOpenclawClawlingRuntime,
|
|
8
9
|
getOpenclawClawlingRuntime,
|
|
10
|
+
startOpenclawClawlingGateway,
|
|
11
|
+
getOpenclawClawlingClient,
|
|
9
12
|
} from "./runtime.ts";
|
|
13
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
14
|
+
import { sendOpenclawClawlingText } from "./outbound.ts";
|
|
15
|
+
|
|
16
|
+
function baseAccount(
|
|
17
|
+
overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
|
|
18
|
+
): ResolvedOpenclawClawlingAccount {
|
|
19
|
+
return {
|
|
20
|
+
accountId: "default",
|
|
21
|
+
name: "openclaw-clawchat",
|
|
22
|
+
enabled: true,
|
|
23
|
+
configured: true,
|
|
24
|
+
websocketUrl: "ws://t",
|
|
25
|
+
baseUrl: "https://api.example.com",
|
|
26
|
+
token: "tk",
|
|
27
|
+
userId: "u",
|
|
28
|
+
ownerUserId: "owner-u",
|
|
29
|
+
replyMode: "static",
|
|
30
|
+
groupMode: "all",
|
|
31
|
+
groups: {},
|
|
32
|
+
forwardThinking: true,
|
|
33
|
+
forwardToolCalls: false,
|
|
34
|
+
richInteractions: false,
|
|
35
|
+
allowFrom: [],
|
|
36
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
37
|
+
reconnect: {
|
|
38
|
+
initialDelay: 1000,
|
|
39
|
+
maxDelay: 30000,
|
|
40
|
+
jitterRatio: 0.3,
|
|
41
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
42
|
+
},
|
|
43
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
44
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const EXPECTED_ACTIVATION_BOOTSTRAP_TEXT = [
|
|
50
|
+
"ClawChat activation bootstrap: You are now connected to this ClawChat direct conversation.",
|
|
51
|
+
"Please do both:",
|
|
52
|
+
"1. Send a brief, friendly greeting to the user in this ClawChat direct conversation.",
|
|
53
|
+
"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.",
|
|
54
|
+
"Do not ask the user for profile information just for this bootstrap.",
|
|
55
|
+
].join("\n");
|
|
56
|
+
|
|
57
|
+
const EXPECTED_CLAWCHAT_GROUP_SYSTEM_PROMPT = [
|
|
58
|
+
"When replying in a ClawChat group, treat the conversation as a shared space. Stay concise, respect the group topic, and avoid exposing hidden runtime context.",
|
|
59
|
+
"",
|
|
60
|
+
"**给 Agents**",
|
|
61
|
+
"你是群组里的伙伴。请理解我们在做的事,理解每一位成员。",
|
|
62
|
+
"也请通过群慢慢和每个人建立关系——你是我们的一员。",
|
|
63
|
+
"",
|
|
64
|
+
"**两条底线**",
|
|
65
|
+
"1. 被 @ 时必须回复",
|
|
66
|
+
"2. Agent 之间连续对话不超过 10 轮,除非有人类伙伴明确允许",
|
|
67
|
+
].join("\n");
|
|
68
|
+
|
|
69
|
+
function buildTestInboundContext(params: {
|
|
70
|
+
channel: string;
|
|
71
|
+
accountId?: string;
|
|
72
|
+
provider?: string;
|
|
73
|
+
surface?: string;
|
|
74
|
+
messageId?: string;
|
|
75
|
+
messageIdFull?: string;
|
|
76
|
+
timestamp?: number;
|
|
77
|
+
from: string;
|
|
78
|
+
sender: { id: string; name?: string; displayLabel?: string };
|
|
79
|
+
conversation: { kind: "direct" | "group" | "channel"; label?: string };
|
|
80
|
+
route: { accountId?: string; routeSessionKey: string; dispatchSessionKey?: string };
|
|
81
|
+
reply: { to: string; originatingTo: string };
|
|
82
|
+
message: { body?: string; rawBody: string; bodyForAgent?: string; commandBody?: string };
|
|
83
|
+
access?: { mentions?: { wasMentioned?: boolean } };
|
|
84
|
+
supplemental?: { groupSystemPrompt?: string };
|
|
85
|
+
}) {
|
|
86
|
+
return {
|
|
87
|
+
Body: params.message.body ?? params.message.rawBody,
|
|
88
|
+
BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
|
|
89
|
+
RawBody: params.message.rawBody,
|
|
90
|
+
CommandBody: params.message.commandBody ?? params.message.rawBody,
|
|
91
|
+
From: params.from,
|
|
92
|
+
To: params.reply.to,
|
|
93
|
+
SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
|
|
94
|
+
AccountId: params.route.accountId ?? params.accountId,
|
|
95
|
+
MessageSid: params.messageId,
|
|
96
|
+
MessageSidFull: params.messageIdFull,
|
|
97
|
+
ChatType: params.conversation.kind,
|
|
98
|
+
ConversationLabel: params.conversation.label,
|
|
99
|
+
GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
|
|
100
|
+
GroupSystemPrompt: params.supplemental?.groupSystemPrompt,
|
|
101
|
+
SenderName: params.sender.name ?? params.sender.displayLabel,
|
|
102
|
+
SenderId: params.sender.id,
|
|
103
|
+
Timestamp: params.timestamp,
|
|
104
|
+
Provider: params.provider ?? params.channel,
|
|
105
|
+
Surface: params.surface ?? params.provider ?? params.channel,
|
|
106
|
+
WasMentioned: params.access?.mentions?.wasMentioned,
|
|
107
|
+
OriginatingChannel: params.channel,
|
|
108
|
+
OriginatingTo: params.reply.originatingTo,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function completeHandshake(
|
|
113
|
+
transport: MockTransport,
|
|
114
|
+
challengeTraceId = "challenge-bootstrap",
|
|
115
|
+
helloPayload: Record<string, unknown> = {},
|
|
116
|
+
): Promise<Record<string, unknown>> {
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
transport.emitInbound(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
version: "2",
|
|
121
|
+
event: "connect.challenge",
|
|
122
|
+
trace_id: challengeTraceId,
|
|
123
|
+
emitted_at: Date.now(),
|
|
124
|
+
payload: { nonce: `${challengeTraceId}-nonce` },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
const connectFrame = transport.sent
|
|
128
|
+
.map((raw) => JSON.parse(raw) as Record<string, unknown>)
|
|
129
|
+
.filter((env) => env.event === "connect")
|
|
130
|
+
.at(-1)!;
|
|
131
|
+
transport.emitInbound(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
version: "2",
|
|
134
|
+
event: "hello-ok",
|
|
135
|
+
trace_id: connectFrame.trace_id,
|
|
136
|
+
emitted_at: Date.now(),
|
|
137
|
+
payload: helloPayload,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
await Promise.resolve();
|
|
141
|
+
return connectFrame;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function jsonEnvelope(data: unknown, status = 200): Response {
|
|
145
|
+
return new Response(JSON.stringify(data), {
|
|
146
|
+
status,
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function conversationDetails(id: string, overrides: Record<string, unknown> = {}) {
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
type: "group",
|
|
155
|
+
title: `Room ${id}`,
|
|
156
|
+
description: `Description ${id}`,
|
|
157
|
+
creator_id: "user-owner",
|
|
158
|
+
created_at: "2026-05-21T10:00:00.000Z",
|
|
159
|
+
updated_at: "2026-05-21T10:01:00.000Z",
|
|
160
|
+
participants: [
|
|
161
|
+
{
|
|
162
|
+
conversation_id: id,
|
|
163
|
+
user_id: "user-owner",
|
|
164
|
+
role: "owner",
|
|
165
|
+
joined_at: "2026-05-21T10:00:30.000Z",
|
|
166
|
+
nickname: "Owner",
|
|
167
|
+
avatar_url: "https://cdn.example/owner.png",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
...overrides,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildNoDispatchRuntime(dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined)) {
|
|
175
|
+
return {
|
|
176
|
+
channel: {
|
|
177
|
+
routing: {
|
|
178
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
179
|
+
agentId: "default",
|
|
180
|
+
accountId: "default",
|
|
181
|
+
sessionKey: "session-from-route",
|
|
182
|
+
})),
|
|
183
|
+
},
|
|
184
|
+
session: {
|
|
185
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
186
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
187
|
+
},
|
|
188
|
+
reply: {
|
|
189
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
190
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
191
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
192
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
193
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
194
|
+
dispatcher: {},
|
|
195
|
+
replyOptions: {},
|
|
196
|
+
markDispatchIdle: vi.fn(),
|
|
197
|
+
markRunComplete: vi.fn(),
|
|
198
|
+
})),
|
|
199
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
|
|
200
|
+
dispatchReplyFromConfig,
|
|
201
|
+
},
|
|
202
|
+
turn: {
|
|
203
|
+
buildContext: vi.fn((params) =>
|
|
204
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
media: {
|
|
208
|
+
fetchRemoteMedia: vi.fn(),
|
|
209
|
+
saveMediaBuffer: vi.fn(),
|
|
210
|
+
loadWebMedia: vi.fn(),
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
} as unknown as PluginRuntime;
|
|
214
|
+
}
|
|
10
215
|
|
|
11
216
|
describe("openclaw-clawchat runtime helpers", () => {
|
|
12
|
-
it("maps
|
|
217
|
+
it("maps local client states to channel status shape", () => {
|
|
13
218
|
expect(mapClawlingStateToStatus("connected")).toMatchObject({
|
|
14
219
|
connected: true,
|
|
15
220
|
running: true,
|
|
@@ -18,43 +223,1746 @@ describe("openclaw-clawchat runtime helpers", () => {
|
|
|
18
223
|
connected: false,
|
|
19
224
|
running: true,
|
|
20
225
|
});
|
|
21
|
-
expect(mapClawlingStateToStatus("disconnected")).toMatchObject({
|
|
22
|
-
connected: false,
|
|
23
|
-
running: false,
|
|
226
|
+
expect(mapClawlingStateToStatus("disconnected")).toMatchObject({
|
|
227
|
+
connected: false,
|
|
228
|
+
running: false,
|
|
229
|
+
});
|
|
230
|
+
expect(mapClawlingStateToStatus("connecting")).toMatchObject({
|
|
231
|
+
connected: false,
|
|
232
|
+
running: true,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("classifies AuthError as fatal/no-retry", () => {
|
|
237
|
+
const c = classifyClawlingClientError(new AuthError("bad-token"));
|
|
238
|
+
expect(c.kind).toBe("auth");
|
|
239
|
+
expect(c.retry).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("classifies generic errors as unknown", () => {
|
|
243
|
+
const c = classifyClawlingClientError(new Error("huh"));
|
|
244
|
+
expect(c.kind).toBe("unknown");
|
|
245
|
+
expect(c.retry).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("runtime store round-trips", () => {
|
|
249
|
+
const rt = { mocked: true } as unknown as PluginRuntime;
|
|
250
|
+
setOpenclawClawlingRuntime(rt);
|
|
251
|
+
expect(getOpenclawClawlingRuntime()).toBe(rt);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("logs auth_failed and does not reconnect after hello-fail", async () => {
|
|
255
|
+
const logs: string[] = [];
|
|
256
|
+
const transport = new MockTransport();
|
|
257
|
+
const account = baseAccount();
|
|
258
|
+
const abortController = new AbortController();
|
|
259
|
+
|
|
260
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
261
|
+
const run = startOpenclawClawlingGateway({
|
|
262
|
+
cfg: {},
|
|
263
|
+
account,
|
|
264
|
+
abortSignal: abortController.signal,
|
|
265
|
+
setStatus: () => {},
|
|
266
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
267
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
268
|
+
transport,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await Promise.resolve();
|
|
272
|
+
transport.emitInbound(
|
|
273
|
+
JSON.stringify({
|
|
274
|
+
version: "2",
|
|
275
|
+
event: "connect.challenge",
|
|
276
|
+
trace_id: "challenge-1",
|
|
277
|
+
emitted_at: Date.now(),
|
|
278
|
+
payload: { nonce: "nonce-1" },
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
const connectFrame = transport.sent
|
|
282
|
+
.map((raw) => JSON.parse(raw))
|
|
283
|
+
.find((env) => env.event === "connect");
|
|
284
|
+
expect(logs).toContain(
|
|
285
|
+
"clawchat.ws event=connect_start account_id=default attempt=1 reconnect_count=0 state=connecting action=connect url=ws://t queue_size=0",
|
|
286
|
+
);
|
|
287
|
+
expect(logs).toContain(
|
|
288
|
+
"clawchat.ws event=challenge_received account_id=default attempt=1 reconnect_count=0 state=handshaking action=send_connect challenge_trace_id=challenge-1 has_nonce=true",
|
|
289
|
+
);
|
|
290
|
+
expect(logs).toContain(
|
|
291
|
+
"clawchat.ws event=connect_sent account_id=default attempt=1 reconnect_count=0 state=handshaking action=await_hello trace_id=" +
|
|
292
|
+
connectFrame.trace_id +
|
|
293
|
+
" device_id=u",
|
|
294
|
+
);
|
|
295
|
+
transport.emitInbound(
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
version: "2",
|
|
298
|
+
event: "hello-fail",
|
|
299
|
+
trace_id: connectFrame.trace_id,
|
|
300
|
+
emitted_at: Date.now(),
|
|
301
|
+
payload: { reason: "authentication failed" },
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
await run;
|
|
306
|
+
|
|
307
|
+
expect(logs).toContain(
|
|
308
|
+
"clawchat.ws event=auth_failed account_id=default attempt=1 reconnect_count=0 state=auth_failed action=stop_reconnect trace_id=" +
|
|
309
|
+
connectFrame.trace_id +
|
|
310
|
+
" reason=authentication failed",
|
|
311
|
+
);
|
|
312
|
+
expect(logs.some((line) => line.includes("event=handshake_ok"))).toBe(false);
|
|
313
|
+
expect(logs.some((line) => line.includes("event=reconnect_scheduled"))).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("logs canonical websocket lifecycle for the first successful connect", async () => {
|
|
317
|
+
const logs: string[] = [];
|
|
318
|
+
const transport = new MockTransport();
|
|
319
|
+
const abortController = new AbortController();
|
|
320
|
+
|
|
321
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
322
|
+
const run = startOpenclawClawlingGateway({
|
|
323
|
+
cfg: {},
|
|
324
|
+
account: baseAccount(),
|
|
325
|
+
abortSignal: abortController.signal,
|
|
326
|
+
setStatus: () => {},
|
|
327
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
328
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
329
|
+
transport,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await Promise.resolve();
|
|
333
|
+
transport.emitInbound(
|
|
334
|
+
JSON.stringify({
|
|
335
|
+
version: "2",
|
|
336
|
+
event: "connect.challenge",
|
|
337
|
+
trace_id: "challenge-1",
|
|
338
|
+
emitted_at: Date.now(),
|
|
339
|
+
payload: { nonce: "nonce-1" },
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
const connectFrame = transport.sent
|
|
343
|
+
.map((raw) => JSON.parse(raw))
|
|
344
|
+
.find((env) => env.event === "connect");
|
|
345
|
+
transport.emitInbound(
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
version: "2",
|
|
348
|
+
event: "hello-ok",
|
|
349
|
+
trace_id: connectFrame.trace_id,
|
|
350
|
+
emitted_at: Date.now(),
|
|
351
|
+
payload: {},
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
await Promise.resolve();
|
|
355
|
+
|
|
356
|
+
expect(logs).toContain(
|
|
357
|
+
"clawchat.ws event=connect_start account_id=default attempt=1 reconnect_count=0 state=connecting action=connect url=ws://t queue_size=0",
|
|
358
|
+
);
|
|
359
|
+
expect(logs).toContain(
|
|
360
|
+
"clawchat.ws event=challenge_received account_id=default attempt=1 reconnect_count=0 state=handshaking action=send_connect challenge_trace_id=challenge-1 has_nonce=true",
|
|
361
|
+
);
|
|
362
|
+
expect(logs).toContain(
|
|
363
|
+
"clawchat.ws event=connect_sent account_id=default attempt=1 reconnect_count=0 state=handshaking action=await_hello trace_id=" +
|
|
364
|
+
connectFrame.trace_id +
|
|
365
|
+
" device_id=u",
|
|
366
|
+
);
|
|
367
|
+
expect(logs).toContainEqual(
|
|
368
|
+
expect.stringMatching(
|
|
369
|
+
new RegExp(
|
|
370
|
+
"^clawchat\\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=0 state=ready action=flush_queue trace_id=" +
|
|
371
|
+
connectFrame.trace_id +
|
|
372
|
+
" elapsed_ms=\\d+ queue_size=0$",
|
|
373
|
+
),
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
abortController.abort();
|
|
378
|
+
await run;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("records websocket lifecycle calls in connection order", async () => {
|
|
382
|
+
const calls: string[] = [];
|
|
383
|
+
const transport = new MockTransport();
|
|
384
|
+
const abortController = new AbortController();
|
|
385
|
+
const store = {
|
|
386
|
+
startConnection: vi.fn(() => {
|
|
387
|
+
calls.push("startConnection");
|
|
388
|
+
return 101;
|
|
389
|
+
}),
|
|
390
|
+
markConnectSent: vi.fn(() => calls.push("markConnectSent")),
|
|
391
|
+
markConnectionReady: vi.fn(() => calls.push("markConnectionReady")),
|
|
392
|
+
finishConnection: vi.fn((_id, input) => calls.push(`finishConnection:${input.state}`)),
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
396
|
+
const run = startOpenclawClawlingGateway({
|
|
397
|
+
cfg: {},
|
|
398
|
+
account: baseAccount(),
|
|
399
|
+
abortSignal: abortController.signal,
|
|
400
|
+
setStatus: () => {},
|
|
401
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
402
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
403
|
+
transport,
|
|
404
|
+
store,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await Promise.resolve();
|
|
408
|
+
transport.emitInbound(
|
|
409
|
+
JSON.stringify({
|
|
410
|
+
version: "2",
|
|
411
|
+
event: "connect.challenge",
|
|
412
|
+
trace_id: "challenge-1",
|
|
413
|
+
emitted_at: Date.now(),
|
|
414
|
+
payload: { nonce: "nonce-1" },
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
const connectFrame = transport.sent
|
|
418
|
+
.map((raw) => JSON.parse(raw))
|
|
419
|
+
.find((env) => env.event === "connect");
|
|
420
|
+
transport.emitInbound(
|
|
421
|
+
JSON.stringify({
|
|
422
|
+
version: "2",
|
|
423
|
+
event: "hello-ok",
|
|
424
|
+
trace_id: connectFrame.trace_id,
|
|
425
|
+
emitted_at: Date.now(),
|
|
426
|
+
payload: {},
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
await Promise.resolve();
|
|
430
|
+
|
|
431
|
+
abortController.abort();
|
|
432
|
+
await run;
|
|
433
|
+
|
|
434
|
+
expect(calls).toEqual([
|
|
435
|
+
"startConnection",
|
|
436
|
+
"markConnectSent",
|
|
437
|
+
"markConnectionReady",
|
|
438
|
+
"finishConnection:disconnected",
|
|
439
|
+
]);
|
|
440
|
+
expect(store.startConnection).toHaveBeenCalledWith(
|
|
441
|
+
expect.objectContaining({
|
|
442
|
+
platform: "openclaw",
|
|
443
|
+
accountId: "default",
|
|
444
|
+
attempt: 1,
|
|
445
|
+
reconnectCount: 0,
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
expect(store.markConnectSent).toHaveBeenCalledWith(101);
|
|
449
|
+
expect(store.markConnectionReady).toHaveBeenCalledWith(101);
|
|
450
|
+
expect(store.finishConnection).toHaveBeenCalledWith(
|
|
451
|
+
101,
|
|
452
|
+
expect.objectContaining({ state: "disconnected", closeCode: 1000 }),
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("records hello-ok device metadata when marking a connection ready", async () => {
|
|
457
|
+
const transport = new MockTransport();
|
|
458
|
+
const abortController = new AbortController();
|
|
459
|
+
const store = {
|
|
460
|
+
startConnection: vi.fn(() => 111),
|
|
461
|
+
markConnectSent: vi.fn(),
|
|
462
|
+
markConnectionReady: vi.fn(),
|
|
463
|
+
finishConnection: vi.fn(),
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
467
|
+
const run = startOpenclawClawlingGateway({
|
|
468
|
+
cfg: {},
|
|
469
|
+
account: baseAccount(),
|
|
470
|
+
abortSignal: abortController.signal,
|
|
471
|
+
setStatus: () => {},
|
|
472
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
473
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
474
|
+
transport,
|
|
475
|
+
store,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await Promise.resolve();
|
|
479
|
+
transport.emitInbound(
|
|
480
|
+
JSON.stringify({
|
|
481
|
+
version: "2",
|
|
482
|
+
event: "connect.challenge",
|
|
483
|
+
trace_id: "challenge-1",
|
|
484
|
+
emitted_at: Date.now(),
|
|
485
|
+
payload: { nonce: "nonce-1" },
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
const connectFrame = transport.sent
|
|
489
|
+
.map((raw) => JSON.parse(raw))
|
|
490
|
+
.find((env) => env.event === "connect");
|
|
491
|
+
transport.emitInbound(
|
|
492
|
+
JSON.stringify({
|
|
493
|
+
version: "2",
|
|
494
|
+
event: "hello-ok",
|
|
495
|
+
trace_id: connectFrame.trace_id,
|
|
496
|
+
emitted_at: Date.now(),
|
|
497
|
+
payload: { device_id: "device-resolved", delivery_mode: "device_replay" },
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
await Promise.resolve();
|
|
501
|
+
|
|
502
|
+
abortController.abort();
|
|
503
|
+
await run;
|
|
504
|
+
|
|
505
|
+
expect(store.markConnectionReady).toHaveBeenCalledWith(
|
|
506
|
+
111,
|
|
507
|
+
expect.objectContaining({
|
|
508
|
+
resolvedDeviceId: "device-resolved",
|
|
509
|
+
deliveryMode: "device_replay",
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("refreshes metadata invalidations without dispatching an agent turn", async () => {
|
|
515
|
+
const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
|
|
516
|
+
const runtime = buildNoDispatchRuntime(dispatchReplyFromConfig);
|
|
517
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
518
|
+
jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-1") } }),
|
|
519
|
+
);
|
|
520
|
+
const store = {
|
|
521
|
+
startConnection: vi.fn(() => 121),
|
|
522
|
+
markConnectSent: vi.fn(),
|
|
523
|
+
markConnectionReady: vi.fn(),
|
|
524
|
+
finishConnection: vi.fn(),
|
|
525
|
+
getCachedConversation: vi.fn(() => ({
|
|
526
|
+
conversationId: "group-1",
|
|
527
|
+
conversationType: "group",
|
|
528
|
+
metadataVersion: 4,
|
|
529
|
+
lastSeenAt: 1,
|
|
530
|
+
lastRefreshedAt: 1,
|
|
531
|
+
})),
|
|
532
|
+
upsertConversationDetails: vi.fn(),
|
|
533
|
+
deleteConversationCache: vi.fn(),
|
|
534
|
+
};
|
|
535
|
+
const transport = new MockTransport();
|
|
536
|
+
const abortController = new AbortController();
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
setOpenclawClawlingRuntime(runtime);
|
|
540
|
+
const run = startOpenclawClawlingGateway({
|
|
541
|
+
cfg: {},
|
|
542
|
+
account: baseAccount(),
|
|
543
|
+
abortSignal: abortController.signal,
|
|
544
|
+
setStatus: vi.fn(),
|
|
545
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
546
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
547
|
+
transport,
|
|
548
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await completeHandshake(transport, "challenge-meta-refresh");
|
|
552
|
+
transport.emitInbound(JSON.stringify({
|
|
553
|
+
version: "2",
|
|
554
|
+
event: "chat.metadata.invalidated",
|
|
555
|
+
trace_id: "meta-refresh",
|
|
556
|
+
emitted_at: Date.now(),
|
|
557
|
+
chat_id: "group-1",
|
|
558
|
+
chat_type: "group",
|
|
559
|
+
payload: { scope: ["unknown"], version: 7 },
|
|
560
|
+
}));
|
|
561
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
562
|
+
|
|
563
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
564
|
+
"https://api.example.com/v1/conversations/group-1",
|
|
565
|
+
expect.objectContaining({ method: "GET" }),
|
|
566
|
+
);
|
|
567
|
+
expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
|
|
568
|
+
platform: "openclaw",
|
|
569
|
+
accountId: "default",
|
|
570
|
+
conversationId: "group-1",
|
|
571
|
+
conversationType: "group",
|
|
572
|
+
metadataVersion: 7,
|
|
573
|
+
groupProfile: expect.objectContaining({ title: "Room group-1", metadataVersion: 7 }),
|
|
574
|
+
userProfiles: [expect.objectContaining({ userId: "user-owner", nickname: "Owner" })],
|
|
575
|
+
members: [expect.objectContaining({ userId: "user-owner", role: "owner" })],
|
|
576
|
+
membersComplete: true,
|
|
577
|
+
}));
|
|
578
|
+
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
579
|
+
|
|
580
|
+
abortController.abort();
|
|
581
|
+
await run;
|
|
582
|
+
} finally {
|
|
583
|
+
fetchMock.mockRestore();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("logs metadata invalidations without chat_id and skips stale versions", async () => {
|
|
588
|
+
const logs: string[] = [];
|
|
589
|
+
const runtime = buildNoDispatchRuntime();
|
|
590
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
591
|
+
jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-stale") } }),
|
|
592
|
+
);
|
|
593
|
+
const store = {
|
|
594
|
+
startConnection: vi.fn(() => 122),
|
|
595
|
+
markConnectSent: vi.fn(),
|
|
596
|
+
markConnectionReady: vi.fn(),
|
|
597
|
+
finishConnection: vi.fn(),
|
|
598
|
+
getCachedConversation: vi.fn(() => ({
|
|
599
|
+
conversationId: "group-stale",
|
|
600
|
+
conversationType: "group",
|
|
601
|
+
metadataVersion: 9,
|
|
602
|
+
lastSeenAt: 1,
|
|
603
|
+
lastRefreshedAt: 1,
|
|
604
|
+
})),
|
|
605
|
+
upsertConversationDetails: vi.fn(),
|
|
606
|
+
};
|
|
607
|
+
const transport = new MockTransport();
|
|
608
|
+
const abortController = new AbortController();
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
setOpenclawClawlingRuntime(runtime);
|
|
612
|
+
const run = startOpenclawClawlingGateway({
|
|
613
|
+
cfg: {},
|
|
614
|
+
account: baseAccount(),
|
|
615
|
+
abortSignal: abortController.signal,
|
|
616
|
+
setStatus: vi.fn(),
|
|
617
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
618
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
619
|
+
transport,
|
|
620
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
await completeHandshake(transport, "challenge-meta-stale");
|
|
624
|
+
transport.emitInbound(JSON.stringify({
|
|
625
|
+
version: "2",
|
|
626
|
+
event: "chat.metadata.invalidated",
|
|
627
|
+
trace_id: "meta-missing-chat",
|
|
628
|
+
emitted_at: Date.now(),
|
|
629
|
+
payload: { version: 10 },
|
|
630
|
+
}));
|
|
631
|
+
transport.emitInbound(JSON.stringify({
|
|
632
|
+
version: "2",
|
|
633
|
+
event: "chat.metadata.invalidated",
|
|
634
|
+
trace_id: "meta-stale",
|
|
635
|
+
emitted_at: Date.now(),
|
|
636
|
+
chat_id: "group-stale",
|
|
637
|
+
payload: { version: 9 },
|
|
638
|
+
}));
|
|
639
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
640
|
+
|
|
641
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
642
|
+
expect(store.upsertConversationDetails).not.toHaveBeenCalled();
|
|
643
|
+
expect(logs.some((line) => line.includes("metadata invalidation missing chat_id"))).toBe(true);
|
|
644
|
+
|
|
645
|
+
abortController.abort();
|
|
646
|
+
await run;
|
|
647
|
+
} finally {
|
|
648
|
+
fetchMock.mockRestore();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("refreshes metadata invalidations without a version and deletes scoped cache on not found", async () => {
|
|
653
|
+
const runtime = buildNoDispatchRuntime();
|
|
654
|
+
const fetchMock = vi.spyOn(globalThis, "fetch")
|
|
655
|
+
.mockResolvedValueOnce(jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails("group-no-version") } }))
|
|
656
|
+
.mockResolvedValueOnce(jsonEnvelope({ code: 404, msg: "conversation not found", data: {} }));
|
|
657
|
+
const store = {
|
|
658
|
+
startConnection: vi.fn(() => 123),
|
|
659
|
+
markConnectSent: vi.fn(),
|
|
660
|
+
markConnectionReady: vi.fn(),
|
|
661
|
+
finishConnection: vi.fn(),
|
|
662
|
+
getCachedConversation: vi.fn(() => ({
|
|
663
|
+
conversationId: "group-no-version",
|
|
664
|
+
conversationType: "group",
|
|
665
|
+
metadataVersion: 99,
|
|
666
|
+
lastSeenAt: 1,
|
|
667
|
+
lastRefreshedAt: 1,
|
|
668
|
+
})),
|
|
669
|
+
upsertConversationDetails: vi.fn(),
|
|
670
|
+
deleteConversationCache: vi.fn(),
|
|
671
|
+
};
|
|
672
|
+
const transport = new MockTransport();
|
|
673
|
+
const abortController = new AbortController();
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
setOpenclawClawlingRuntime(runtime);
|
|
677
|
+
const run = startOpenclawClawlingGateway({
|
|
678
|
+
cfg: {},
|
|
679
|
+
account: baseAccount(),
|
|
680
|
+
abortSignal: abortController.signal,
|
|
681
|
+
setStatus: vi.fn(),
|
|
682
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
683
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
684
|
+
transport,
|
|
685
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
await completeHandshake(transport, "challenge-meta-noversion");
|
|
689
|
+
transport.emitInbound(JSON.stringify({
|
|
690
|
+
version: "2",
|
|
691
|
+
event: "chat.metadata.invalidated",
|
|
692
|
+
trace_id: "meta-no-version",
|
|
693
|
+
emitted_at: Date.now(),
|
|
694
|
+
chat_id: "group-no-version",
|
|
695
|
+
payload: {},
|
|
696
|
+
}));
|
|
697
|
+
transport.emitInbound(JSON.stringify({
|
|
698
|
+
version: "2",
|
|
699
|
+
event: "chat.metadata.invalidated",
|
|
700
|
+
trace_id: "meta-not-found",
|
|
701
|
+
emitted_at: Date.now(),
|
|
702
|
+
chat_id: "group-missing",
|
|
703
|
+
payload: { version: 100 },
|
|
704
|
+
}));
|
|
705
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
706
|
+
|
|
707
|
+
expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
|
|
708
|
+
conversationId: "group-no-version",
|
|
709
|
+
}));
|
|
710
|
+
expect(store.deleteConversationCache).toHaveBeenCalledWith({
|
|
711
|
+
platform: "openclaw",
|
|
712
|
+
accountId: "default",
|
|
713
|
+
conversationId: "group-missing",
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
abortController.abort();
|
|
717
|
+
await run;
|
|
718
|
+
} finally {
|
|
719
|
+
fetchMock.mockRestore();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("logs metadata refresh errors without advancing the cached version", async () => {
|
|
724
|
+
const logs: string[] = [];
|
|
725
|
+
const runtime = buildNoDispatchRuntime();
|
|
726
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
727
|
+
new Response("gateway unavailable", { status: 500 }),
|
|
728
|
+
);
|
|
729
|
+
const store = {
|
|
730
|
+
startConnection: vi.fn(() => 124),
|
|
731
|
+
markConnectSent: vi.fn(),
|
|
732
|
+
markConnectionReady: vi.fn(),
|
|
733
|
+
finishConnection: vi.fn(),
|
|
734
|
+
getCachedConversation: vi.fn(() => ({
|
|
735
|
+
conversationId: "group-error",
|
|
736
|
+
conversationType: "group",
|
|
737
|
+
metadataVersion: 1,
|
|
738
|
+
lastSeenAt: 1,
|
|
739
|
+
lastRefreshedAt: 1,
|
|
740
|
+
})),
|
|
741
|
+
upsertConversationDetails: vi.fn(),
|
|
742
|
+
deleteConversationCache: vi.fn(),
|
|
743
|
+
};
|
|
744
|
+
const transport = new MockTransport();
|
|
745
|
+
const abortController = new AbortController();
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
setOpenclawClawlingRuntime(runtime);
|
|
749
|
+
const run = startOpenclawClawlingGateway({
|
|
750
|
+
cfg: {},
|
|
751
|
+
account: baseAccount(),
|
|
752
|
+
abortSignal: abortController.signal,
|
|
753
|
+
setStatus: vi.fn(),
|
|
754
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
755
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
756
|
+
transport,
|
|
757
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
await completeHandshake(transport, "challenge-meta-error");
|
|
761
|
+
transport.emitInbound(JSON.stringify({
|
|
762
|
+
version: "2",
|
|
763
|
+
event: "chat.metadata.invalidated",
|
|
764
|
+
trace_id: "meta-error",
|
|
765
|
+
emitted_at: Date.now(),
|
|
766
|
+
chat_id: "group-error",
|
|
767
|
+
payload: { version: 2 },
|
|
768
|
+
}));
|
|
769
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
770
|
+
|
|
771
|
+
expect(store.upsertConversationDetails).not.toHaveBeenCalled();
|
|
772
|
+
expect(store.deleteConversationCache).not.toHaveBeenCalled();
|
|
773
|
+
expect(logs.some((line) => line.includes("metadata refresh failed"))).toBe(true);
|
|
774
|
+
|
|
775
|
+
abortController.abort();
|
|
776
|
+
await run;
|
|
777
|
+
} finally {
|
|
778
|
+
fetchMock.mockRestore();
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("refreshes activation and cached conversations after hello-ok without writing tool calls", async () => {
|
|
783
|
+
const runtime = buildNoDispatchRuntime();
|
|
784
|
+
const requestedIds: string[] = [];
|
|
785
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
786
|
+
const id = String(input).split("/").at(-1)!;
|
|
787
|
+
requestedIds.push(id);
|
|
788
|
+
if (id === "cached-fail") {
|
|
789
|
+
return new Response("oops", { status: 500 });
|
|
790
|
+
}
|
|
791
|
+
return jsonEnvelope({ code: 0, msg: "ok", data: { conversation: conversationDetails(id) } });
|
|
792
|
+
});
|
|
793
|
+
const cachedIds = ["cached-1", "activation-1", "cached-fail", ...Array.from({ length: 25 }, (_, i) => `cached-${i + 2}`)];
|
|
794
|
+
const store = {
|
|
795
|
+
startConnection: vi.fn(() => 125),
|
|
796
|
+
markConnectSent: vi.fn(),
|
|
797
|
+
markConnectionReady: vi.fn(),
|
|
798
|
+
finishConnection: vi.fn(),
|
|
799
|
+
getActivationConversation: vi.fn(() => ({
|
|
800
|
+
conversationId: "activation-1",
|
|
801
|
+
conversationType: "direct",
|
|
802
|
+
metadataVersion: null,
|
|
803
|
+
lastSeenAt: null,
|
|
804
|
+
lastRefreshedAt: null,
|
|
805
|
+
})),
|
|
806
|
+
listCachedConversationIds: vi.fn(() => cachedIds),
|
|
807
|
+
upsertConversationDetails: vi.fn(),
|
|
808
|
+
deleteConversationCache: vi.fn(),
|
|
809
|
+
recordToolCall: vi.fn(),
|
|
810
|
+
};
|
|
811
|
+
const transport = new MockTransport();
|
|
812
|
+
const abortController = new AbortController();
|
|
813
|
+
|
|
814
|
+
try {
|
|
815
|
+
setOpenclawClawlingRuntime(runtime);
|
|
816
|
+
const run = startOpenclawClawlingGateway({
|
|
817
|
+
cfg: {},
|
|
818
|
+
account: baseAccount(),
|
|
819
|
+
abortSignal: abortController.signal,
|
|
820
|
+
setStatus: vi.fn(),
|
|
821
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
822
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
823
|
+
transport,
|
|
824
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
await completeHandshake(transport, "challenge-fresh-fetch");
|
|
828
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
829
|
+
|
|
830
|
+
expect(store.listCachedConversationIds).toHaveBeenCalledWith({
|
|
831
|
+
platform: "openclaw",
|
|
832
|
+
accountId: "default",
|
|
833
|
+
limit: 20,
|
|
834
|
+
});
|
|
835
|
+
expect(requestedIds[0]).toBe("activation-1");
|
|
836
|
+
expect(requestedIds.filter((id) => id === "activation-1")).toHaveLength(1);
|
|
837
|
+
expect(requestedIds).toContain("cached-1");
|
|
838
|
+
expect(requestedIds).toContain("cached-fail");
|
|
839
|
+
expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
|
|
840
|
+
conversationId: "cached-1",
|
|
841
|
+
}));
|
|
842
|
+
expect(store.upsertConversationDetails).toHaveBeenCalledWith(expect.objectContaining({
|
|
843
|
+
conversationId: "cached-2",
|
|
844
|
+
}));
|
|
845
|
+
expect(store.recordToolCall).not.toHaveBeenCalled();
|
|
846
|
+
|
|
847
|
+
abortController.abort();
|
|
848
|
+
await run;
|
|
849
|
+
} finally {
|
|
850
|
+
fetchMock.mockRestore();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("records auth failure and transport error as terminal connection states", async () => {
|
|
855
|
+
const authTransport = new MockTransport();
|
|
856
|
+
const authStore = {
|
|
857
|
+
startConnection: vi.fn(() => 201),
|
|
858
|
+
markConnectSent: vi.fn(),
|
|
859
|
+
markConnectionReady: vi.fn(),
|
|
860
|
+
finishConnection: vi.fn(),
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
864
|
+
const authRun = startOpenclawClawlingGateway({
|
|
865
|
+
cfg: {},
|
|
866
|
+
account: baseAccount(),
|
|
867
|
+
abortSignal: new AbortController().signal,
|
|
868
|
+
setStatus: () => {},
|
|
869
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
870
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
871
|
+
transport: authTransport,
|
|
872
|
+
store: authStore,
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
await Promise.resolve();
|
|
876
|
+
authTransport.emitInbound(
|
|
877
|
+
JSON.stringify({
|
|
878
|
+
version: "2",
|
|
879
|
+
event: "connect.challenge",
|
|
880
|
+
trace_id: "challenge-auth",
|
|
881
|
+
emitted_at: Date.now(),
|
|
882
|
+
payload: { nonce: "nonce-1" },
|
|
883
|
+
}),
|
|
884
|
+
);
|
|
885
|
+
const authConnectFrame = authTransport.sent
|
|
886
|
+
.map((raw) => JSON.parse(raw))
|
|
887
|
+
.find((env) => env.event === "connect");
|
|
888
|
+
authTransport.emitInbound(
|
|
889
|
+
JSON.stringify({
|
|
890
|
+
version: "2",
|
|
891
|
+
event: "hello-fail",
|
|
892
|
+
trace_id: authConnectFrame.trace_id,
|
|
893
|
+
emitted_at: Date.now(),
|
|
894
|
+
payload: { reason: "authentication failed" },
|
|
895
|
+
}),
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
await authRun;
|
|
899
|
+
|
|
900
|
+
expect(authStore.finishConnection).toHaveBeenCalledWith(
|
|
901
|
+
201,
|
|
902
|
+
expect.objectContaining({ state: "auth_failed", error: "authentication failed" }),
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const transport = new MockTransport();
|
|
906
|
+
const abortController = new AbortController();
|
|
907
|
+
const transportStore = {
|
|
908
|
+
startConnection: vi.fn(() => 301),
|
|
909
|
+
markConnectSent: vi.fn(),
|
|
910
|
+
markConnectionReady: vi.fn(),
|
|
911
|
+
finishConnection: vi.fn(),
|
|
912
|
+
};
|
|
913
|
+
const transportRun = startOpenclawClawlingGateway({
|
|
914
|
+
cfg: {},
|
|
915
|
+
account: baseAccount(),
|
|
916
|
+
abortSignal: abortController.signal,
|
|
917
|
+
setStatus: () => {},
|
|
918
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
919
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
920
|
+
transport,
|
|
921
|
+
store: transportStore,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
await Promise.resolve();
|
|
925
|
+
transport.emitInbound(
|
|
926
|
+
JSON.stringify({
|
|
927
|
+
version: "2",
|
|
928
|
+
event: "connect.challenge",
|
|
929
|
+
trace_id: "challenge-transport",
|
|
930
|
+
emitted_at: Date.now(),
|
|
931
|
+
payload: { nonce: "nonce-1" },
|
|
932
|
+
}),
|
|
933
|
+
);
|
|
934
|
+
const transportConnectFrame = transport.sent
|
|
935
|
+
.map((raw) => JSON.parse(raw))
|
|
936
|
+
.find((env) => env.event === "connect");
|
|
937
|
+
transport.emitInbound(
|
|
938
|
+
JSON.stringify({
|
|
939
|
+
version: "2",
|
|
940
|
+
event: "hello-ok",
|
|
941
|
+
trace_id: transportConnectFrame.trace_id,
|
|
942
|
+
emitted_at: Date.now(),
|
|
943
|
+
payload: {},
|
|
944
|
+
}),
|
|
945
|
+
);
|
|
946
|
+
await Promise.resolve();
|
|
947
|
+
transport.emitError(new Error("socket down"));
|
|
948
|
+
await Promise.resolve();
|
|
949
|
+
abortController.abort();
|
|
950
|
+
await transportRun;
|
|
951
|
+
|
|
952
|
+
expect(transportStore.finishConnection).toHaveBeenCalledWith(
|
|
953
|
+
301,
|
|
954
|
+
expect.objectContaining({ state: "transport_error", error: "socket down" }),
|
|
955
|
+
);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("logs handshake_ok with the connect trace", async () => {
|
|
959
|
+
const logs: string[] = [];
|
|
960
|
+
const transport = new MockTransport();
|
|
961
|
+
const abortController = new AbortController();
|
|
962
|
+
|
|
963
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
964
|
+
const run = startOpenclawClawlingGateway({
|
|
965
|
+
cfg: {},
|
|
966
|
+
account: baseAccount(),
|
|
967
|
+
abortSignal: abortController.signal,
|
|
968
|
+
setStatus: () => {},
|
|
969
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
970
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
971
|
+
transport,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
await Promise.resolve();
|
|
975
|
+
transport.emitInbound(
|
|
976
|
+
JSON.stringify({
|
|
977
|
+
version: "2",
|
|
978
|
+
event: "connect.challenge",
|
|
979
|
+
trace_id: "challenge-1",
|
|
980
|
+
emitted_at: Date.now(),
|
|
981
|
+
payload: { nonce: "nonce-1" },
|
|
982
|
+
}),
|
|
983
|
+
);
|
|
984
|
+
const connectFrame = transport.sent
|
|
985
|
+
.map((raw) => JSON.parse(raw))
|
|
986
|
+
.find((env) => env.event === "connect");
|
|
987
|
+
transport.emitInbound(
|
|
988
|
+
JSON.stringify({
|
|
989
|
+
version: "2",
|
|
990
|
+
event: "hello-ok",
|
|
991
|
+
trace_id: connectFrame.trace_id,
|
|
992
|
+
emitted_at: Date.now(),
|
|
993
|
+
payload: {},
|
|
994
|
+
}),
|
|
995
|
+
);
|
|
996
|
+
await Promise.resolve();
|
|
997
|
+
|
|
998
|
+
expect(logs).toContainEqual(
|
|
999
|
+
expect.stringMatching(
|
|
1000
|
+
new RegExp(
|
|
1001
|
+
"^clawchat\\.ws event=handshake_ok account_id=default attempt=1 reconnect_count=0 state=ready action=flush_queue trace_id=" +
|
|
1002
|
+
connectFrame.trace_id +
|
|
1003
|
+
" elapsed_ms=\\d+ queue_size=0$",
|
|
1004
|
+
),
|
|
1005
|
+
),
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
abortController.abort();
|
|
1009
|
+
await run;
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("logs JSON ping and pong as protocol control", async () => {
|
|
1013
|
+
const logs: string[] = [];
|
|
1014
|
+
const transport = new MockTransport();
|
|
1015
|
+
const abortController = new AbortController();
|
|
1016
|
+
|
|
1017
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
1018
|
+
const run = startOpenclawClawlingGateway({
|
|
1019
|
+
cfg: {},
|
|
1020
|
+
account: baseAccount(),
|
|
1021
|
+
abortSignal: abortController.signal,
|
|
1022
|
+
setStatus: () => {},
|
|
1023
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
1024
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1025
|
+
transport,
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
await Promise.resolve();
|
|
1029
|
+
transport.emitInbound(
|
|
1030
|
+
JSON.stringify({
|
|
1031
|
+
version: "2",
|
|
1032
|
+
event: "connect.challenge",
|
|
1033
|
+
trace_id: "challenge-1",
|
|
1034
|
+
emitted_at: Date.now(),
|
|
1035
|
+
payload: { nonce: "nonce-1" },
|
|
1036
|
+
}),
|
|
1037
|
+
);
|
|
1038
|
+
const connectFrame = transport.sent
|
|
1039
|
+
.map((raw) => JSON.parse(raw))
|
|
1040
|
+
.find((env) => env.event === "connect");
|
|
1041
|
+
transport.emitInbound(
|
|
1042
|
+
JSON.stringify({
|
|
1043
|
+
version: "2",
|
|
1044
|
+
event: "hello-ok",
|
|
1045
|
+
trace_id: connectFrame.trace_id,
|
|
1046
|
+
emitted_at: Date.now(),
|
|
1047
|
+
payload: {},
|
|
1048
|
+
}),
|
|
1049
|
+
);
|
|
1050
|
+
await Promise.resolve();
|
|
1051
|
+
transport.sent.length = 0;
|
|
1052
|
+
|
|
1053
|
+
transport.emitInbound(
|
|
1054
|
+
JSON.stringify({
|
|
1055
|
+
version: "2",
|
|
1056
|
+
event: "ping",
|
|
1057
|
+
trace_id: "trace-ping",
|
|
1058
|
+
emitted_at: Date.now(),
|
|
1059
|
+
payload: {},
|
|
1060
|
+
}),
|
|
1061
|
+
);
|
|
1062
|
+
transport.emitInbound(
|
|
1063
|
+
JSON.stringify({
|
|
1064
|
+
version: "2",
|
|
1065
|
+
event: "pong",
|
|
1066
|
+
trace_id: "trace-pong",
|
|
1067
|
+
emitted_at: Date.now(),
|
|
1068
|
+
payload: {},
|
|
1069
|
+
}),
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
expect(transport.sent.map((raw) => JSON.parse(raw))).toContainEqual(
|
|
1073
|
+
expect.objectContaining({ event: "pong", trace_id: "trace-ping" }),
|
|
1074
|
+
);
|
|
1075
|
+
expect(logs).toContain(
|
|
1076
|
+
"clawchat.ws event=protocol_ping_received account_id=default attempt=1 reconnect_count=0 state=ready action=send_pong trace_id=trace-ping",
|
|
1077
|
+
);
|
|
1078
|
+
expect(logs).toContain(
|
|
1079
|
+
"clawchat.ws event=protocol_pong_received account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-pong",
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
abortController.abort();
|
|
1083
|
+
await run;
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("logs unknown ready-state events as inbound_ignored", async () => {
|
|
1087
|
+
const logs: string[] = [];
|
|
1088
|
+
const transport = new MockTransport();
|
|
1089
|
+
const abortController = new AbortController();
|
|
1090
|
+
|
|
1091
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
1092
|
+
const run = startOpenclawClawlingGateway({
|
|
1093
|
+
cfg: {},
|
|
1094
|
+
account: baseAccount(),
|
|
1095
|
+
abortSignal: abortController.signal,
|
|
1096
|
+
setStatus: () => {},
|
|
1097
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
1098
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1099
|
+
transport,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
await Promise.resolve();
|
|
1103
|
+
transport.emitInbound(
|
|
1104
|
+
JSON.stringify({
|
|
1105
|
+
version: "2",
|
|
1106
|
+
event: "connect.challenge",
|
|
1107
|
+
trace_id: "challenge-1",
|
|
1108
|
+
emitted_at: Date.now(),
|
|
1109
|
+
payload: { nonce: "nonce-1" },
|
|
1110
|
+
}),
|
|
1111
|
+
);
|
|
1112
|
+
const connectFrame = transport.sent
|
|
1113
|
+
.map((raw) => JSON.parse(raw))
|
|
1114
|
+
.find((env) => env.event === "connect");
|
|
1115
|
+
transport.emitInbound(
|
|
1116
|
+
JSON.stringify({
|
|
1117
|
+
version: "2",
|
|
1118
|
+
event: "hello-ok",
|
|
1119
|
+
trace_id: connectFrame.trace_id,
|
|
1120
|
+
emitted_at: Date.now(),
|
|
1121
|
+
payload: {},
|
|
1122
|
+
}),
|
|
1123
|
+
);
|
|
1124
|
+
await Promise.resolve();
|
|
1125
|
+
|
|
1126
|
+
transport.emitInbound(
|
|
1127
|
+
JSON.stringify({
|
|
1128
|
+
version: "2",
|
|
1129
|
+
event: "custom.event",
|
|
1130
|
+
trace_id: "trace-custom",
|
|
1131
|
+
emitted_at: Date.now(),
|
|
1132
|
+
payload: {},
|
|
1133
|
+
}),
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
expect(logs).toContain(
|
|
1137
|
+
"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",
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
abortController.abort();
|
|
1141
|
+
await run;
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("auto flushes queued outbound when runtime observes connected", async () => {
|
|
1145
|
+
const logs: string[] = [];
|
|
1146
|
+
const transport = new MockTransport();
|
|
1147
|
+
const abortController = new AbortController();
|
|
1148
|
+
const account = baseAccount({
|
|
1149
|
+
ack: { timeout: 15000, autoResendOnTimeout: false },
|
|
1150
|
+
reconnect: {
|
|
1151
|
+
initialDelay: 1,
|
|
1152
|
+
maxDelay: 1,
|
|
1153
|
+
jitterRatio: 0,
|
|
1154
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
1159
|
+
const run = startOpenclawClawlingGateway({
|
|
1160
|
+
cfg: {},
|
|
1161
|
+
account,
|
|
1162
|
+
abortSignal: abortController.signal,
|
|
1163
|
+
setStatus: () => {},
|
|
1164
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
1165
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1166
|
+
transport,
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
await Promise.resolve();
|
|
1170
|
+
transport.emitInbound(
|
|
1171
|
+
JSON.stringify({
|
|
1172
|
+
version: "2",
|
|
1173
|
+
event: "connect.challenge",
|
|
1174
|
+
trace_id: "challenge-1",
|
|
1175
|
+
emitted_at: Date.now(),
|
|
1176
|
+
payload: { nonce: "nonce-1" },
|
|
1177
|
+
}),
|
|
1178
|
+
);
|
|
1179
|
+
const connectFrame = transport.sent
|
|
1180
|
+
.map((raw) => JSON.parse(raw))
|
|
1181
|
+
.find((env) => env.event === "connect");
|
|
1182
|
+
transport.emitInbound(
|
|
1183
|
+
JSON.stringify({
|
|
1184
|
+
version: "2",
|
|
1185
|
+
event: "hello-ok",
|
|
1186
|
+
trace_id: connectFrame.trace_id,
|
|
1187
|
+
emitted_at: Date.now(),
|
|
1188
|
+
payload: {},
|
|
1189
|
+
}),
|
|
1190
|
+
);
|
|
1191
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1192
|
+
|
|
1193
|
+
const client = getOpenclawClawlingClient("default")!;
|
|
1194
|
+
transport.close(1006, "network lost");
|
|
1195
|
+
await Promise.resolve();
|
|
1196
|
+
|
|
1197
|
+
let sendResult: unknown;
|
|
1198
|
+
const sendPromise = sendOpenclawClawlingText({
|
|
1199
|
+
client,
|
|
1200
|
+
account,
|
|
1201
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1202
|
+
text: "queued while reconnecting",
|
|
1203
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1204
|
+
}).then((result) => {
|
|
1205
|
+
sendResult = result;
|
|
1206
|
+
return result;
|
|
1207
|
+
});
|
|
1208
|
+
await Promise.resolve();
|
|
1209
|
+
|
|
1210
|
+
const sentBeforeReady = transport.sent.length;
|
|
1211
|
+
expect(logs.some((line) => line.includes("event=send_queued"))).toBe(true);
|
|
1212
|
+
|
|
1213
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1214
|
+
transport.emitInbound(
|
|
1215
|
+
JSON.stringify({
|
|
1216
|
+
version: "2",
|
|
1217
|
+
event: "connect.challenge",
|
|
1218
|
+
trace_id: "challenge-2",
|
|
1219
|
+
emitted_at: Date.now(),
|
|
1220
|
+
payload: { nonce: "nonce-2" },
|
|
1221
|
+
}),
|
|
1222
|
+
);
|
|
1223
|
+
const secondConnectFrame = transport.sent
|
|
1224
|
+
.map((raw) => JSON.parse(raw))
|
|
1225
|
+
.filter((env) => env.event === "connect")
|
|
1226
|
+
.at(-1);
|
|
1227
|
+
transport.emitInbound(
|
|
1228
|
+
JSON.stringify({
|
|
1229
|
+
version: "2",
|
|
1230
|
+
event: "hello-ok",
|
|
1231
|
+
trace_id: secondConnectFrame.trace_id,
|
|
1232
|
+
emitted_at: Date.now(),
|
|
1233
|
+
payload: {},
|
|
1234
|
+
}),
|
|
1235
|
+
);
|
|
1236
|
+
await Promise.resolve();
|
|
1237
|
+
|
|
1238
|
+
expect(logs).toContainEqual(
|
|
1239
|
+
expect.stringMatching(
|
|
1240
|
+
/^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$/,
|
|
1241
|
+
),
|
|
1242
|
+
);
|
|
1243
|
+
expect(transport.sent.length).toBe(sentBeforeReady + 2);
|
|
1244
|
+
const queuedFrame = JSON.parse(transport.sent.at(-1)!);
|
|
1245
|
+
expect(queuedFrame.event).toBe("message.send");
|
|
1246
|
+
expect(logs.some((line) => line.includes("event=send_flush"))).toBe(true);
|
|
1247
|
+
|
|
1248
|
+
transport.emitInbound(
|
|
1249
|
+
JSON.stringify({
|
|
1250
|
+
version: "2",
|
|
1251
|
+
event: "message.ack",
|
|
1252
|
+
trace_id: queuedFrame.trace_id,
|
|
1253
|
+
emitted_at: Date.now(),
|
|
1254
|
+
chat_id: "chat-1",
|
|
1255
|
+
payload: { message_id: "server-1", accepted_at: 1234 },
|
|
1256
|
+
}),
|
|
1257
|
+
);
|
|
1258
|
+
await sendPromise;
|
|
1259
|
+
expect(sendResult).toEqual({ messageId: "server-1", acceptedAt: 1234 });
|
|
1260
|
+
|
|
1261
|
+
abortController.abort();
|
|
1262
|
+
await run;
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it("uses real attempt and reconnect_count in websocket logs across reconnect", async () => {
|
|
1266
|
+
vi.useFakeTimers();
|
|
1267
|
+
const logs: string[] = [];
|
|
1268
|
+
const transport = new MockTransport();
|
|
1269
|
+
const abortController = new AbortController();
|
|
1270
|
+
|
|
1271
|
+
setOpenclawClawlingRuntime({ mocked: true } as unknown as PluginRuntime);
|
|
1272
|
+
const run = startOpenclawClawlingGateway({
|
|
1273
|
+
cfg: {},
|
|
1274
|
+
account: baseAccount({
|
|
1275
|
+
reconnect: {
|
|
1276
|
+
initialDelay: 1000,
|
|
1277
|
+
maxDelay: 30000,
|
|
1278
|
+
jitterRatio: 0,
|
|
1279
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
1280
|
+
},
|
|
1281
|
+
}),
|
|
1282
|
+
abortSignal: abortController.signal,
|
|
1283
|
+
setStatus: () => {},
|
|
1284
|
+
getStatus: () => ({ connected: false, configured: true, running: true }),
|
|
1285
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1286
|
+
transport,
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
await Promise.resolve();
|
|
1290
|
+
transport.emitInbound(
|
|
1291
|
+
JSON.stringify({
|
|
1292
|
+
version: "2",
|
|
1293
|
+
event: "connect.challenge",
|
|
1294
|
+
trace_id: "challenge-1",
|
|
1295
|
+
emitted_at: Date.now(),
|
|
1296
|
+
payload: { nonce: "nonce-1" },
|
|
1297
|
+
}),
|
|
1298
|
+
);
|
|
1299
|
+
const firstConnectFrame = transport.sent
|
|
1300
|
+
.map((raw) => JSON.parse(raw))
|
|
1301
|
+
.find((env) => env.event === "connect");
|
|
1302
|
+
transport.emitInbound(
|
|
1303
|
+
JSON.stringify({
|
|
1304
|
+
version: "2",
|
|
1305
|
+
event: "hello-ok",
|
|
1306
|
+
trace_id: firstConnectFrame.trace_id,
|
|
1307
|
+
emitted_at: Date.now(),
|
|
1308
|
+
payload: {},
|
|
1309
|
+
}),
|
|
1310
|
+
);
|
|
1311
|
+
await Promise.resolve();
|
|
1312
|
+
|
|
1313
|
+
transport.close(1006, "network lost");
|
|
1314
|
+
await Promise.resolve();
|
|
1315
|
+
expect(logs).toContain(
|
|
1316
|
+
"clawchat.ws event=connection_lost account_id=default attempt=1 reconnect_count=0 state=ready action=reconnect code=1006 reason=network lost",
|
|
1317
|
+
);
|
|
1318
|
+
expect(logs).toContain(
|
|
1319
|
+
"clawchat.ws event=reconnect_scheduled account_id=default attempt=1 reconnect_count=1 state=reconnecting action=wait delay_ms=1000 max_delay_ms=30000 reason=connection_lost",
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
1323
|
+
await Promise.resolve();
|
|
1324
|
+
transport.emitInbound(
|
|
1325
|
+
JSON.stringify({
|
|
1326
|
+
version: "2",
|
|
1327
|
+
event: "connect.challenge",
|
|
1328
|
+
trace_id: "challenge-2",
|
|
1329
|
+
emitted_at: Date.now(),
|
|
1330
|
+
payload: { nonce: "nonce-2" },
|
|
1331
|
+
}),
|
|
1332
|
+
);
|
|
1333
|
+
const secondConnectFrame = transport.sent
|
|
1334
|
+
.map((raw) => JSON.parse(raw))
|
|
1335
|
+
.filter((env) => env.event === "connect")
|
|
1336
|
+
.at(-1);
|
|
1337
|
+
transport.emitInbound(
|
|
1338
|
+
JSON.stringify({
|
|
1339
|
+
version: "2",
|
|
1340
|
+
event: "hello-ok",
|
|
1341
|
+
trace_id: secondConnectFrame.trace_id,
|
|
1342
|
+
emitted_at: Date.now(),
|
|
1343
|
+
payload: {},
|
|
1344
|
+
}),
|
|
1345
|
+
);
|
|
1346
|
+
await Promise.resolve();
|
|
1347
|
+
|
|
1348
|
+
transport.emitInbound(
|
|
1349
|
+
JSON.stringify({
|
|
1350
|
+
version: "2",
|
|
1351
|
+
event: "custom.event",
|
|
1352
|
+
trace_id: "trace-after-reconnect",
|
|
1353
|
+
emitted_at: Date.now(),
|
|
1354
|
+
payload: {},
|
|
1355
|
+
}),
|
|
1356
|
+
);
|
|
1357
|
+
expect(logs).toContain(
|
|
1358
|
+
"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",
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
1362
|
+
expect(logs).toContain(
|
|
1363
|
+
"clawchat.ws event=reconnect_backoff_reset account_id=default attempt=2 reconnect_count=0 state=ready action=reset stable_ms=5000",
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
abortController.abort();
|
|
1367
|
+
await run;
|
|
1368
|
+
vi.useRealTimers();
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
describe("openclaw-clawchat runtime media ingest", () => {
|
|
1373
|
+
it("claims complete inbound messages but not streaming created/add fragments", async () => {
|
|
1374
|
+
const runtime = {
|
|
1375
|
+
channel: {
|
|
1376
|
+
routing: {
|
|
1377
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
1378
|
+
agentId: "u",
|
|
1379
|
+
accountId: "default",
|
|
1380
|
+
sessionKey: "s",
|
|
1381
|
+
})),
|
|
1382
|
+
},
|
|
1383
|
+
session: {
|
|
1384
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
1385
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
1386
|
+
},
|
|
1387
|
+
reply: {
|
|
1388
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
1389
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
1390
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
1391
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
1392
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
1393
|
+
dispatcher: {},
|
|
1394
|
+
replyOptions: {},
|
|
1395
|
+
markDispatchIdle: vi.fn(),
|
|
1396
|
+
})),
|
|
1397
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
|
|
1398
|
+
await opts.run();
|
|
1399
|
+
}),
|
|
1400
|
+
dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
|
1401
|
+
},
|
|
1402
|
+
turn: {
|
|
1403
|
+
buildContext: vi.fn((params) =>
|
|
1404
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
1405
|
+
),
|
|
1406
|
+
},
|
|
1407
|
+
media: {
|
|
1408
|
+
fetchRemoteMedia: vi.fn(),
|
|
1409
|
+
saveMediaBuffer: vi.fn(),
|
|
1410
|
+
loadWebMedia: vi.fn(),
|
|
1411
|
+
},
|
|
1412
|
+
},
|
|
1413
|
+
} as unknown as PluginRuntime;
|
|
1414
|
+
const store = {
|
|
1415
|
+
startConnection: vi.fn(() => 401),
|
|
1416
|
+
markConnectSent: vi.fn(),
|
|
1417
|
+
markConnectionReady: vi.fn(),
|
|
1418
|
+
finishConnection: vi.fn(),
|
|
1419
|
+
claimMessageOnce: vi.fn(() => true),
|
|
1420
|
+
insertMessage: vi.fn(),
|
|
1421
|
+
upsertConversationSummary: vi.fn(),
|
|
1422
|
+
upsertConversationDetails: vi.fn(),
|
|
1423
|
+
};
|
|
1424
|
+
setOpenclawClawlingRuntime(runtime);
|
|
1425
|
+
const transport = new MockTransport();
|
|
1426
|
+
const abortController = new AbortController();
|
|
1427
|
+
const run = startOpenclawClawlingGateway({
|
|
1428
|
+
cfg: {},
|
|
1429
|
+
account: baseAccount(),
|
|
1430
|
+
abortSignal: abortController.signal,
|
|
1431
|
+
setStatus: vi.fn(),
|
|
1432
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
1433
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
1434
|
+
transport,
|
|
1435
|
+
store,
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
await Promise.resolve();
|
|
1439
|
+
transport.emitInbound(
|
|
1440
|
+
JSON.stringify({
|
|
1441
|
+
version: "2",
|
|
1442
|
+
event: "connect.challenge",
|
|
1443
|
+
trace_id: "challenge-inbound-persist",
|
|
1444
|
+
emitted_at: Date.now(),
|
|
1445
|
+
payload: { nonce: "nonce" },
|
|
1446
|
+
}),
|
|
1447
|
+
);
|
|
1448
|
+
const connectFrame = transport.sent
|
|
1449
|
+
.map((raw) => JSON.parse(raw))
|
|
1450
|
+
.find((env) => env.event === "connect");
|
|
1451
|
+
transport.emitInbound(
|
|
1452
|
+
JSON.stringify({
|
|
1453
|
+
version: "2",
|
|
1454
|
+
event: "hello-ok",
|
|
1455
|
+
trace_id: connectFrame.trace_id,
|
|
1456
|
+
emitted_at: Date.now(),
|
|
1457
|
+
payload: {},
|
|
1458
|
+
}),
|
|
1459
|
+
);
|
|
1460
|
+
await Promise.resolve();
|
|
1461
|
+
|
|
1462
|
+
for (const event of ["message.created", "message.add"]) {
|
|
1463
|
+
transport.emitInbound(
|
|
1464
|
+
JSON.stringify({
|
|
1465
|
+
version: "2",
|
|
1466
|
+
event,
|
|
1467
|
+
trace_id: `trace-${event}`,
|
|
1468
|
+
emitted_at: Date.now(),
|
|
1469
|
+
chat_id: "chat-1",
|
|
1470
|
+
chat_type: "direct",
|
|
1471
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
1472
|
+
payload: { message_id: "stream-fragment", fragments: [{ kind: "text", text: "part" }] },
|
|
1473
|
+
}),
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
transport.emitInbound(
|
|
1478
|
+
JSON.stringify({
|
|
1479
|
+
version: "2",
|
|
1480
|
+
event: "message.send",
|
|
1481
|
+
trace_id: "trace-inbound-complete",
|
|
1482
|
+
emitted_at: 12345,
|
|
1483
|
+
chat_id: "chat-1",
|
|
1484
|
+
chat_type: "direct",
|
|
1485
|
+
to: { id: "u", type: "direct" },
|
|
1486
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
1487
|
+
payload: {
|
|
1488
|
+
message_id: "m-persist-inbound",
|
|
1489
|
+
message_mode: "normal",
|
|
1490
|
+
message: {
|
|
1491
|
+
body: { fragments: [{ kind: "text", text: "hello persisted" }] },
|
|
1492
|
+
context: { mentions: [], reply: null },
|
|
1493
|
+
streaming: {
|
|
1494
|
+
status: "static",
|
|
1495
|
+
sequence: 0,
|
|
1496
|
+
mutation_policy: "sealed",
|
|
1497
|
+
started_at: null,
|
|
1498
|
+
completed_at: null,
|
|
1499
|
+
},
|
|
1500
|
+
},
|
|
1501
|
+
},
|
|
1502
|
+
}),
|
|
1503
|
+
);
|
|
1504
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1505
|
+
abortController.abort();
|
|
1506
|
+
await run;
|
|
1507
|
+
|
|
1508
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
1509
|
+
expect(store.upsertConversationSummary).toHaveBeenCalledTimes(1);
|
|
1510
|
+
expect(store.upsertConversationSummary).toHaveBeenCalledWith(expect.objectContaining({
|
|
1511
|
+
platform: "openclaw",
|
|
1512
|
+
accountId: "default",
|
|
1513
|
+
conversationId: "chat-1",
|
|
1514
|
+
conversationType: "direct",
|
|
1515
|
+
lastSeenAt: 12345,
|
|
1516
|
+
}));
|
|
1517
|
+
expect(store.upsertConversationDetails).not.toHaveBeenCalled();
|
|
1518
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith({
|
|
1519
|
+
platform: "openclaw",
|
|
1520
|
+
accountId: "default",
|
|
1521
|
+
kind: "message",
|
|
1522
|
+
direction: "inbound",
|
|
1523
|
+
eventType: "message.send",
|
|
1524
|
+
traceId: "trace-inbound-complete",
|
|
1525
|
+
chatId: "chat-1",
|
|
1526
|
+
messageId: "m-persist-inbound",
|
|
1527
|
+
text: "hello persisted",
|
|
1528
|
+
raw: expect.objectContaining({ event: "message.send" }),
|
|
1529
|
+
});
|
|
1530
|
+
expect(store.insertMessage).not.toHaveBeenCalled();
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it("does not dispatch duplicate inbound messages already claimed in storage", async () => {
|
|
1534
|
+
const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
|
|
1535
|
+
counts: { final: 1, block: 0, tool: 0 },
|
|
1536
|
+
queuedFinal: true,
|
|
1537
|
+
});
|
|
1538
|
+
const claimMessageOnce = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
|
|
1539
|
+
const runtime = {
|
|
1540
|
+
channel: {
|
|
1541
|
+
routing: {
|
|
1542
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
1543
|
+
agentId: "default",
|
|
1544
|
+
accountId: "default",
|
|
1545
|
+
sessionKey: "s",
|
|
1546
|
+
})),
|
|
1547
|
+
},
|
|
1548
|
+
session: {
|
|
1549
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
1550
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
1551
|
+
},
|
|
1552
|
+
reply: {
|
|
1553
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
1554
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
1555
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
1556
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
1557
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
1558
|
+
dispatcher: {},
|
|
1559
|
+
replyOptions: {},
|
|
1560
|
+
markDispatchIdle: vi.fn(),
|
|
1561
|
+
markRunComplete: vi.fn(),
|
|
1562
|
+
})),
|
|
1563
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
|
|
1564
|
+
dispatchReplyFromConfig,
|
|
1565
|
+
},
|
|
1566
|
+
turn: {
|
|
1567
|
+
buildContext: vi.fn((params) =>
|
|
1568
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
1569
|
+
),
|
|
1570
|
+
},
|
|
1571
|
+
media: {
|
|
1572
|
+
fetchRemoteMedia: vi.fn(),
|
|
1573
|
+
saveMediaBuffer: vi.fn(),
|
|
1574
|
+
loadWebMedia: vi.fn(),
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
} as unknown as PluginRuntime;
|
|
1578
|
+
setOpenclawClawlingRuntime(runtime);
|
|
1579
|
+
const transport = new MockTransport();
|
|
1580
|
+
const abortController = new AbortController();
|
|
1581
|
+
|
|
1582
|
+
const run = startOpenclawClawlingGateway({
|
|
1583
|
+
cfg: {},
|
|
1584
|
+
account: baseAccount(),
|
|
1585
|
+
abortSignal: abortController.signal,
|
|
1586
|
+
setStatus: vi.fn(),
|
|
1587
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
1588
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
1589
|
+
transport,
|
|
1590
|
+
store: {
|
|
1591
|
+
startConnection: vi.fn(() => 1),
|
|
1592
|
+
markConnectSent: vi.fn(),
|
|
1593
|
+
markConnectionReady: vi.fn(),
|
|
1594
|
+
finishConnection: vi.fn(),
|
|
1595
|
+
claimMessageOnce,
|
|
1596
|
+
},
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
await Promise.resolve();
|
|
1600
|
+
transport.emitInbound(
|
|
1601
|
+
JSON.stringify({
|
|
1602
|
+
version: "2",
|
|
1603
|
+
event: "connect.challenge",
|
|
1604
|
+
trace_id: "challenge",
|
|
1605
|
+
emitted_at: Date.now(),
|
|
1606
|
+
payload: { nonce: "nonce" },
|
|
1607
|
+
}),
|
|
1608
|
+
);
|
|
1609
|
+
const connectFrame = transport.sent
|
|
1610
|
+
.map((raw) => JSON.parse(raw))
|
|
1611
|
+
.find((env) => env.event === "connect");
|
|
1612
|
+
transport.emitInbound(
|
|
1613
|
+
JSON.stringify({
|
|
1614
|
+
version: "2",
|
|
1615
|
+
event: "hello-ok",
|
|
1616
|
+
trace_id: connectFrame.trace_id,
|
|
1617
|
+
emitted_at: Date.now(),
|
|
1618
|
+
payload: {},
|
|
1619
|
+
}),
|
|
1620
|
+
);
|
|
1621
|
+
await Promise.resolve();
|
|
1622
|
+
|
|
1623
|
+
const duplicateFrame = {
|
|
1624
|
+
version: "2",
|
|
1625
|
+
event: "message.send",
|
|
1626
|
+
trace_id: "dup-trace",
|
|
1627
|
+
emitted_at: Date.now(),
|
|
1628
|
+
chat_id: "chat-1",
|
|
1629
|
+
chat_type: "direct",
|
|
1630
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
1631
|
+
payload: {
|
|
1632
|
+
message_id: "duplicate-message",
|
|
1633
|
+
message_mode: "normal",
|
|
1634
|
+
message: {
|
|
1635
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
1636
|
+
context: { mentions: [], reply: null },
|
|
1637
|
+
streaming: {
|
|
1638
|
+
status: "static",
|
|
1639
|
+
sequence: 0,
|
|
1640
|
+
mutation_policy: "sealed",
|
|
1641
|
+
started_at: null,
|
|
1642
|
+
completed_at: null,
|
|
1643
|
+
},
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
};
|
|
1647
|
+
transport.emitInbound(JSON.stringify(duplicateFrame));
|
|
1648
|
+
transport.emitInbound(JSON.stringify({ ...duplicateFrame, trace_id: "dup-trace-2" }));
|
|
1649
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1650
|
+
abortController.abort();
|
|
1651
|
+
await run;
|
|
1652
|
+
|
|
1653
|
+
expect(claimMessageOnce).toHaveBeenCalledTimes(2);
|
|
1654
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it("dispatches pending activation bootstrap through the normal direct inbound agent path after ready", async () => {
|
|
1658
|
+
const capturedCtxs: Record<string, unknown>[] = [];
|
|
1659
|
+
const resolveAgentRoute = vi.fn(() => ({
|
|
1660
|
+
agentId: "default",
|
|
1661
|
+
accountId: "default",
|
|
1662
|
+
sessionKey: "session-from-route",
|
|
1663
|
+
}));
|
|
1664
|
+
const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
|
|
1665
|
+
counts: { final: 1, block: 0, tool: 0 },
|
|
1666
|
+
queuedFinal: true,
|
|
1667
|
+
});
|
|
1668
|
+
const runtime = {
|
|
1669
|
+
channel: {
|
|
1670
|
+
routing: { resolveAgentRoute },
|
|
1671
|
+
session: {
|
|
1672
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
1673
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
1674
|
+
},
|
|
1675
|
+
reply: {
|
|
1676
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
1677
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
1678
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => {
|
|
1679
|
+
capturedCtxs.push(ctx);
|
|
1680
|
+
return ctx;
|
|
1681
|
+
}),
|
|
1682
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
1683
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
1684
|
+
dispatcher: {},
|
|
1685
|
+
replyOptions: {},
|
|
1686
|
+
markDispatchIdle: vi.fn(),
|
|
1687
|
+
markRunComplete: vi.fn(),
|
|
1688
|
+
})),
|
|
1689
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
|
|
1690
|
+
dispatchReplyFromConfig,
|
|
1691
|
+
},
|
|
1692
|
+
turn: {
|
|
1693
|
+
buildContext: vi.fn((params) => {
|
|
1694
|
+
const ctx = buildTestInboundContext(
|
|
1695
|
+
params as Parameters<typeof buildTestInboundContext>[0],
|
|
1696
|
+
);
|
|
1697
|
+
capturedCtxs.push(ctx);
|
|
1698
|
+
return ctx;
|
|
1699
|
+
}),
|
|
1700
|
+
},
|
|
1701
|
+
media: {
|
|
1702
|
+
fetchRemoteMedia: vi.fn(),
|
|
1703
|
+
saveMediaBuffer: vi.fn(),
|
|
1704
|
+
loadWebMedia: vi.fn(),
|
|
1705
|
+
},
|
|
1706
|
+
},
|
|
1707
|
+
} as unknown as PluginRuntime;
|
|
1708
|
+
const store = {
|
|
1709
|
+
startConnection: vi.fn(() => 501),
|
|
1710
|
+
markConnectSent: vi.fn(),
|
|
1711
|
+
markConnectionReady: vi.fn(),
|
|
1712
|
+
finishConnection: vi.fn(),
|
|
1713
|
+
claimMessageOnce: vi.fn(() => true),
|
|
1714
|
+
claimPendingActivationBootstrap: vi.fn(() => ({ conversationId: "conv-activation" })),
|
|
1715
|
+
markActivationBootstrapSent: vi.fn(() => true),
|
|
1716
|
+
releaseActivationBootstrapClaim: vi.fn(() => true),
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
setOpenclawClawlingRuntime(runtime);
|
|
1720
|
+
const transport = new MockTransport();
|
|
1721
|
+
const abortController = new AbortController();
|
|
1722
|
+
const run = startOpenclawClawlingGateway({
|
|
1723
|
+
cfg: {} as OpenClawConfig,
|
|
1724
|
+
account: baseAccount({ token: "secret-token-value" }),
|
|
1725
|
+
abortSignal: abortController.signal,
|
|
1726
|
+
setStatus: vi.fn(),
|
|
1727
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
1728
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
1729
|
+
transport,
|
|
1730
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
await completeHandshake(transport, "challenge-bootstrap-1");
|
|
1734
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1735
|
+
abortController.abort();
|
|
1736
|
+
await run;
|
|
1737
|
+
|
|
1738
|
+
expect(store.claimPendingActivationBootstrap).toHaveBeenCalledWith({
|
|
1739
|
+
platform: "openclaw",
|
|
1740
|
+
accountId: "default",
|
|
24
1741
|
});
|
|
25
|
-
expect(
|
|
26
|
-
|
|
27
|
-
|
|
1742
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(
|
|
1743
|
+
expect.objectContaining({
|
|
1744
|
+
platform: "openclaw",
|
|
1745
|
+
accountId: "default",
|
|
1746
|
+
kind: "message",
|
|
1747
|
+
direction: "inbound",
|
|
1748
|
+
eventType: "message.send",
|
|
1749
|
+
chatId: "conv-activation",
|
|
1750
|
+
messageId: expect.stringContaining("bootstrap"),
|
|
1751
|
+
}),
|
|
1752
|
+
);
|
|
1753
|
+
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
|
1754
|
+
expect.objectContaining({ peer: { kind: "direct", id: "conv-activation" } }),
|
|
1755
|
+
);
|
|
1756
|
+
expect(capturedCtxs).toHaveLength(1);
|
|
1757
|
+
const ctx = capturedCtxs[0]!;
|
|
1758
|
+
const bodyForAgent = String(ctx.BodyForAgent);
|
|
1759
|
+
expect(ctx.From).toBe("openclaw-clawchat:conv-activation");
|
|
1760
|
+
expect(ctx.OriginatingTo).toBe("openclaw-clawchat:conv-activation");
|
|
1761
|
+
expect(ctx.ChatType).toBe("direct");
|
|
1762
|
+
expect(bodyForAgent).toBe(EXPECTED_ACTIVATION_BOOTSTRAP_TEXT);
|
|
1763
|
+
expect(bodyForAgent).not.toContain("secret-token-value");
|
|
1764
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1765
|
+
expect(store.markActivationBootstrapSent).toHaveBeenCalledWith({
|
|
1766
|
+
platform: "openclaw",
|
|
1767
|
+
accountId: "default",
|
|
1768
|
+
conversationId: "conv-activation",
|
|
28
1769
|
});
|
|
1770
|
+
expect(dispatchReplyFromConfig.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
1771
|
+
store.markActivationBootstrapSent.mock.invocationCallOrder[0]!,
|
|
1772
|
+
);
|
|
29
1773
|
});
|
|
30
1774
|
|
|
31
|
-
it("
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
1775
|
+
it("does not repeat an activation bootstrap across reconnect while the first dispatch is in flight", async () => {
|
|
1776
|
+
let resolveDispatch: (value: unknown) => void = () => {};
|
|
1777
|
+
const dispatchReplyFromConfig = vi.fn(
|
|
1778
|
+
() =>
|
|
1779
|
+
new Promise((resolve) => {
|
|
1780
|
+
resolveDispatch = resolve;
|
|
1781
|
+
}),
|
|
1782
|
+
);
|
|
1783
|
+
const runtime = {
|
|
1784
|
+
channel: {
|
|
1785
|
+
routing: {
|
|
1786
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
1787
|
+
agentId: "default",
|
|
1788
|
+
accountId: "default",
|
|
1789
|
+
sessionKey: "session-from-route",
|
|
1790
|
+
})),
|
|
1791
|
+
},
|
|
1792
|
+
session: {
|
|
1793
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
1794
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
1795
|
+
},
|
|
1796
|
+
reply: {
|
|
1797
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
1798
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
1799
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
1800
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
1801
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
1802
|
+
dispatcher: {},
|
|
1803
|
+
replyOptions: {},
|
|
1804
|
+
markDispatchIdle: vi.fn(),
|
|
1805
|
+
markRunComplete: vi.fn(),
|
|
1806
|
+
})),
|
|
1807
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
|
|
1808
|
+
dispatchReplyFromConfig,
|
|
1809
|
+
},
|
|
1810
|
+
turn: {
|
|
1811
|
+
buildContext: vi.fn((params) =>
|
|
1812
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
1813
|
+
),
|
|
1814
|
+
},
|
|
1815
|
+
media: {
|
|
1816
|
+
fetchRemoteMedia: vi.fn(),
|
|
1817
|
+
saveMediaBuffer: vi.fn(),
|
|
1818
|
+
loadWebMedia: vi.fn(),
|
|
1819
|
+
},
|
|
1820
|
+
},
|
|
1821
|
+
} as unknown as PluginRuntime;
|
|
1822
|
+
const store = {
|
|
1823
|
+
startConnection: vi.fn(() => 601),
|
|
1824
|
+
markConnectSent: vi.fn(),
|
|
1825
|
+
markConnectionReady: vi.fn(),
|
|
1826
|
+
finishConnection: vi.fn(),
|
|
1827
|
+
claimMessageOnce: vi.fn(() => true),
|
|
1828
|
+
claimPendingActivationBootstrap: vi
|
|
1829
|
+
.fn()
|
|
1830
|
+
.mockReturnValueOnce({ conversationId: "conv-activation" })
|
|
1831
|
+
.mockReturnValue(null),
|
|
1832
|
+
markActivationBootstrapSent: vi.fn(() => true),
|
|
1833
|
+
releaseActivationBootstrapClaim: vi.fn(() => true),
|
|
1834
|
+
};
|
|
1835
|
+
setOpenclawClawlingRuntime(runtime);
|
|
1836
|
+
const transport = new MockTransport();
|
|
1837
|
+
const abortController = new AbortController();
|
|
1838
|
+
const run = startOpenclawClawlingGateway({
|
|
1839
|
+
cfg: {} as OpenClawConfig,
|
|
1840
|
+
account: baseAccount({
|
|
1841
|
+
reconnect: {
|
|
1842
|
+
initialDelay: 1,
|
|
1843
|
+
maxDelay: 1,
|
|
1844
|
+
jitterRatio: 0,
|
|
1845
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
1846
|
+
},
|
|
1847
|
+
}),
|
|
1848
|
+
abortSignal: abortController.signal,
|
|
1849
|
+
setStatus: vi.fn(),
|
|
1850
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
1851
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
1852
|
+
transport,
|
|
1853
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
1854
|
+
});
|
|
36
1855
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(
|
|
40
|
-
|
|
1856
|
+
await completeHandshake(transport, "challenge-bootstrap-first");
|
|
1857
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1858
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1859
|
+
|
|
1860
|
+
transport.close(1006, "network lost");
|
|
1861
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1862
|
+
await completeHandshake(transport, "challenge-bootstrap-reconnect");
|
|
1863
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1864
|
+
expect(store.claimPendingActivationBootstrap).toHaveBeenCalledTimes(2);
|
|
1865
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1866
|
+
|
|
1867
|
+
resolveDispatch({ counts: { final: 1, block: 0, tool: 0 }, queuedFinal: true });
|
|
1868
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1869
|
+
abortController.abort();
|
|
1870
|
+
await run;
|
|
1871
|
+
|
|
1872
|
+
expect(store.markActivationBootstrapSent).toHaveBeenCalledTimes(1);
|
|
1873
|
+
expect(store.markActivationBootstrapSent).toHaveBeenCalledWith({
|
|
1874
|
+
platform: "openclaw",
|
|
1875
|
+
accountId: "default",
|
|
1876
|
+
conversationId: "conv-activation",
|
|
1877
|
+
});
|
|
41
1878
|
});
|
|
42
1879
|
|
|
43
|
-
it("
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
1880
|
+
it("releases an activation bootstrap claim when agent submission fails", async () => {
|
|
1881
|
+
const dispatchReplyFromConfig = vi.fn().mockRejectedValue(new Error("dispatch failed"));
|
|
1882
|
+
const runtime = {
|
|
1883
|
+
channel: {
|
|
1884
|
+
routing: {
|
|
1885
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
1886
|
+
agentId: "default",
|
|
1887
|
+
accountId: "default",
|
|
1888
|
+
sessionKey: "session-from-route",
|
|
1889
|
+
})),
|
|
1890
|
+
},
|
|
1891
|
+
session: {
|
|
1892
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
1893
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
1894
|
+
},
|
|
1895
|
+
reply: {
|
|
1896
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
1897
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
1898
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
1899
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
1900
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
1901
|
+
dispatcher: {},
|
|
1902
|
+
replyOptions: {},
|
|
1903
|
+
markDispatchIdle: vi.fn(),
|
|
1904
|
+
markRunComplete: vi.fn(),
|
|
1905
|
+
})),
|
|
1906
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => opts.run()),
|
|
1907
|
+
dispatchReplyFromConfig,
|
|
1908
|
+
},
|
|
1909
|
+
turn: {
|
|
1910
|
+
buildContext: vi.fn((params) =>
|
|
1911
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
1912
|
+
),
|
|
1913
|
+
},
|
|
1914
|
+
media: {
|
|
1915
|
+
fetchRemoteMedia: vi.fn(),
|
|
1916
|
+
saveMediaBuffer: vi.fn(),
|
|
1917
|
+
loadWebMedia: vi.fn(),
|
|
1918
|
+
},
|
|
1919
|
+
},
|
|
1920
|
+
} as unknown as PluginRuntime;
|
|
1921
|
+
const store = {
|
|
1922
|
+
startConnection: vi.fn(() => 701),
|
|
1923
|
+
markConnectSent: vi.fn(),
|
|
1924
|
+
markConnectionReady: vi.fn(),
|
|
1925
|
+
finishConnection: vi.fn(),
|
|
1926
|
+
claimMessageOnce: vi.fn(() => true),
|
|
1927
|
+
claimPendingActivationBootstrap: vi.fn(() => ({ conversationId: "conv-activation" })),
|
|
1928
|
+
markActivationBootstrapSent: vi.fn(() => true),
|
|
1929
|
+
releaseActivationBootstrapClaim: vi.fn(() => true),
|
|
1930
|
+
};
|
|
1931
|
+
setOpenclawClawlingRuntime(runtime);
|
|
1932
|
+
const transport = new MockTransport();
|
|
1933
|
+
const abortController = new AbortController();
|
|
1934
|
+
const run = startOpenclawClawlingGateway({
|
|
1935
|
+
cfg: {} as OpenClawConfig,
|
|
1936
|
+
account: baseAccount(),
|
|
1937
|
+
abortSignal: abortController.signal,
|
|
1938
|
+
setStatus: vi.fn(),
|
|
1939
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
1940
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
1941
|
+
transport,
|
|
1942
|
+
store: store as NonNullable<Parameters<typeof startOpenclawClawlingGateway>[0]["store"]>,
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
await completeHandshake(transport, "challenge-bootstrap-failure");
|
|
1946
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1947
|
+
abortController.abort();
|
|
1948
|
+
await run;
|
|
1949
|
+
|
|
1950
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1951
|
+
expect(store.markActivationBootstrapSent).not.toHaveBeenCalled();
|
|
1952
|
+
expect(store.releaseActivationBootstrapClaim).toHaveBeenCalledWith({
|
|
1953
|
+
platform: "openclaw",
|
|
1954
|
+
accountId: "default",
|
|
1955
|
+
conversationId: "conv-activation",
|
|
1956
|
+
});
|
|
47
1957
|
});
|
|
48
|
-
});
|
|
49
1958
|
|
|
50
|
-
describe("openclaw-clawchat runtime media ingest", () => {
|
|
51
1959
|
it("fetches inbound media via runtime.channel.media and populates MediaPath/MediaPaths", async () => {
|
|
52
1960
|
const fetched: Array<{ url: string }> = [];
|
|
53
1961
|
const saved: Array<{ ct: string | undefined }> = [];
|
|
54
1962
|
let capturedCtx: Record<string, unknown> | undefined;
|
|
55
|
-
const
|
|
56
|
-
capturedCtx =
|
|
57
|
-
return
|
|
1963
|
+
const buildContext = vi.fn((params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) => {
|
|
1964
|
+
capturedCtx = buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
|
|
1965
|
+
return capturedCtx as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>;
|
|
58
1966
|
});
|
|
59
1967
|
const resolveAgentRoute = vi.fn(() => ({
|
|
60
1968
|
agentId: "u",
|
|
@@ -77,7 +1985,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
77
1985
|
reply: {
|
|
78
1986
|
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
79
1987
|
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
80
|
-
finalizeInboundContext,
|
|
1988
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
81
1989
|
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
82
1990
|
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
83
1991
|
dispatcher: {},
|
|
@@ -90,6 +1998,9 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
90
1998
|
}),
|
|
91
1999
|
dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
|
92
2000
|
},
|
|
2001
|
+
turn: {
|
|
2002
|
+
buildContext,
|
|
2003
|
+
},
|
|
93
2004
|
media: {
|
|
94
2005
|
fetchRemoteMedia: vi.fn(async ({ url }: { url: string }) => {
|
|
95
2006
|
fetched.push({ url });
|
|
@@ -107,7 +2018,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
107
2018
|
setOpenclawClawlingRuntime(runtime);
|
|
108
2019
|
|
|
109
2020
|
const { startOpenclawClawlingGateway } = await import("./runtime.ts");
|
|
110
|
-
const { MockTransport } = await import("
|
|
2021
|
+
const { MockTransport } = await import("./mock-transport.ts");
|
|
111
2022
|
const transport = new MockTransport();
|
|
112
2023
|
const abortController = new AbortController();
|
|
113
2024
|
|
|
@@ -153,11 +2064,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
153
2064
|
payload: { nonce: "n" },
|
|
154
2065
|
}),
|
|
155
2066
|
);
|
|
2067
|
+
const connectFrame = transport.sent
|
|
2068
|
+
.map((raw) => JSON.parse(raw))
|
|
2069
|
+
.find((env) => env.event === "connect");
|
|
156
2070
|
transport.emitInbound(
|
|
157
2071
|
JSON.stringify({
|
|
158
2072
|
version: "2",
|
|
159
2073
|
event: "hello-ok",
|
|
160
|
-
trace_id:
|
|
2074
|
+
trace_id: connectFrame.trace_id,
|
|
161
2075
|
emitted_at: Date.now(),
|
|
162
2076
|
payload: {},
|
|
163
2077
|
}),
|
|
@@ -173,7 +2087,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
173
2087
|
chat_id: "chat-1",
|
|
174
2088
|
chat_type: "direct",
|
|
175
2089
|
to: { id: "u", type: "direct" },
|
|
176
|
-
sender: {
|
|
2090
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
177
2091
|
payload: {
|
|
178
2092
|
message_id: "m-with-image",
|
|
179
2093
|
message_mode: "normal",
|
|
@@ -192,7 +2106,6 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
192
2106
|
started_at: null,
|
|
193
2107
|
completed_at: null,
|
|
194
2108
|
},
|
|
195
|
-
sender: { sender_id: "user-1", type: "direct", display_name: "User" },
|
|
196
2109
|
},
|
|
197
2110
|
},
|
|
198
2111
|
}),
|
|
@@ -205,7 +2118,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
205
2118
|
expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
|
|
206
2119
|
expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
|
|
207
2120
|
expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
|
|
2121
|
+
expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:chat-1");
|
|
208
2122
|
expect(capturedCtx?.ConversationLabel).toBe("chat-1");
|
|
2123
|
+
expect(capturedCtx?.GroupSystemPrompt).toBeUndefined();
|
|
2124
|
+
const directBuildContextArg = buildContext.mock.calls[0]?.[0] as
|
|
2125
|
+
| Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
|
|
2126
|
+
| undefined;
|
|
2127
|
+
expect(directBuildContextArg?.conversation.kind).toBe("direct");
|
|
2128
|
+
expect(directBuildContextArg?.supplemental).toBeUndefined();
|
|
209
2129
|
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
210
2130
|
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
|
211
2131
|
expect.objectContaining({
|
|
@@ -223,9 +2143,13 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
223
2143
|
|
|
224
2144
|
it("uses group chat_id as the canonical conversation identity", async () => {
|
|
225
2145
|
let capturedCtx: Record<string, unknown> | undefined;
|
|
226
|
-
const
|
|
227
|
-
capturedCtx =
|
|
228
|
-
return
|
|
2146
|
+
const buildContext = vi.fn((params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) => {
|
|
2147
|
+
capturedCtx = buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]);
|
|
2148
|
+
return capturedCtx as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>;
|
|
2149
|
+
});
|
|
2150
|
+
const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
|
|
2151
|
+
counts: { final: 1, block: 0, tool: 0 },
|
|
2152
|
+
queuedFinal: true,
|
|
229
2153
|
});
|
|
230
2154
|
|
|
231
2155
|
const runtime = {
|
|
@@ -244,7 +2168,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
244
2168
|
reply: {
|
|
245
2169
|
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
246
2170
|
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
247
|
-
finalizeInboundContext,
|
|
2171
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
248
2172
|
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
249
2173
|
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
250
2174
|
dispatcher: {},
|
|
@@ -255,7 +2179,10 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
255
2179
|
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
|
|
256
2180
|
await opts.run();
|
|
257
2181
|
}),
|
|
258
|
-
dispatchReplyFromConfig
|
|
2182
|
+
dispatchReplyFromConfig,
|
|
2183
|
+
},
|
|
2184
|
+
turn: {
|
|
2185
|
+
buildContext,
|
|
259
2186
|
},
|
|
260
2187
|
media: {
|
|
261
2188
|
fetchRemoteMedia: vi.fn(),
|
|
@@ -284,8 +2211,10 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
284
2211
|
userId: "u",
|
|
285
2212
|
replyMode: "static",
|
|
286
2213
|
groupMode: "all",
|
|
2214
|
+
groups: {},
|
|
287
2215
|
forwardThinking: true,
|
|
288
2216
|
forwardToolCalls: false,
|
|
2217
|
+
richInteractions: false,
|
|
289
2218
|
allowFrom: [],
|
|
290
2219
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
291
2220
|
reconnect: {
|
|
@@ -314,11 +2243,14 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
314
2243
|
payload: { nonce: "n" },
|
|
315
2244
|
}),
|
|
316
2245
|
);
|
|
2246
|
+
const connectFrame = transport.sent
|
|
2247
|
+
.map((raw) => JSON.parse(raw))
|
|
2248
|
+
.find((env) => env.event === "connect");
|
|
317
2249
|
transport.emitInbound(
|
|
318
2250
|
JSON.stringify({
|
|
319
2251
|
version: "2",
|
|
320
2252
|
event: "hello-ok",
|
|
321
|
-
trace_id:
|
|
2253
|
+
trace_id: connectFrame.trace_id,
|
|
322
2254
|
emitted_at: Date.now(),
|
|
323
2255
|
payload: {},
|
|
324
2256
|
}),
|
|
@@ -334,7 +2266,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
334
2266
|
chat_id: "grp-1",
|
|
335
2267
|
chat_type: "group",
|
|
336
2268
|
to: { id: "u", type: "group" },
|
|
337
|
-
sender: {
|
|
2269
|
+
sender: { id: "user-1", type: "direct", nick_name: "Alice" },
|
|
338
2270
|
payload: {
|
|
339
2271
|
message_id: "m-group",
|
|
340
2272
|
message_mode: "normal",
|
|
@@ -359,9 +2291,143 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
359
2291
|
await startPromise;
|
|
360
2292
|
|
|
361
2293
|
expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
|
|
2294
|
+
expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:group:grp-1");
|
|
362
2295
|
expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
|
|
363
2296
|
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
364
2297
|
expect(capturedCtx?.ChatType).toBe("group");
|
|
2298
|
+
expect(capturedCtx?.GroupSystemPrompt).toBe(EXPECTED_CLAWCHAT_GROUP_SYSTEM_PROMPT);
|
|
2299
|
+
const groupBuildContextArg = buildContext.mock.calls[0]?.[0] as
|
|
2300
|
+
| Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]
|
|
2301
|
+
| undefined;
|
|
2302
|
+
expect(groupBuildContextArg?.conversation.kind).toBe("group");
|
|
2303
|
+
expect(groupBuildContextArg?.supplemental?.groupSystemPrompt).toBe(
|
|
2304
|
+
EXPECTED_CLAWCHAT_GROUP_SYSTEM_PROMPT,
|
|
2305
|
+
);
|
|
2306
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledWith(
|
|
2307
|
+
expect.objectContaining({
|
|
2308
|
+
replyOptions: expect.objectContaining({
|
|
2309
|
+
sourceReplyDeliveryMode: "automatic",
|
|
2310
|
+
}),
|
|
2311
|
+
}),
|
|
2312
|
+
);
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
it("dispatches completed message.done frames to the OpenClaw agent path", async () => {
|
|
2316
|
+
const dispatchReplyFromConfig = vi.fn().mockResolvedValue({
|
|
2317
|
+
counts: { final: 1, block: 0, tool: 0 },
|
|
2318
|
+
queuedFinal: true,
|
|
2319
|
+
});
|
|
2320
|
+
const runtime = {
|
|
2321
|
+
channel: {
|
|
2322
|
+
routing: {
|
|
2323
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
2324
|
+
agentId: "default",
|
|
2325
|
+
accountId: "default",
|
|
2326
|
+
sessionKey: "s",
|
|
2327
|
+
})),
|
|
2328
|
+
},
|
|
2329
|
+
session: {
|
|
2330
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
2331
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
2332
|
+
},
|
|
2333
|
+
reply: {
|
|
2334
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
2335
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
2336
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
2337
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
2338
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
2339
|
+
dispatcher: {},
|
|
2340
|
+
replyOptions: {},
|
|
2341
|
+
markDispatchIdle: vi.fn(),
|
|
2342
|
+
markRunComplete: vi.fn(),
|
|
2343
|
+
})),
|
|
2344
|
+
withReplyDispatcher: vi.fn(
|
|
2345
|
+
async (opts: { run: () => Promise<unknown>; onSettled?: () => void | Promise<void> }) => {
|
|
2346
|
+
try {
|
|
2347
|
+
return await opts.run();
|
|
2348
|
+
} finally {
|
|
2349
|
+
await opts.onSettled?.();
|
|
2350
|
+
}
|
|
2351
|
+
},
|
|
2352
|
+
),
|
|
2353
|
+
dispatchReplyFromConfig,
|
|
2354
|
+
},
|
|
2355
|
+
turn: {
|
|
2356
|
+
buildContext: vi.fn((params) =>
|
|
2357
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
2358
|
+
),
|
|
2359
|
+
},
|
|
2360
|
+
media: {
|
|
2361
|
+
fetchRemoteMedia: vi.fn(),
|
|
2362
|
+
saveMediaBuffer: vi.fn(),
|
|
2363
|
+
loadWebMedia: vi.fn(),
|
|
2364
|
+
},
|
|
2365
|
+
},
|
|
2366
|
+
} as unknown as PluginRuntime;
|
|
2367
|
+
setOpenclawClawlingRuntime(runtime);
|
|
2368
|
+
const transport = new MockTransport();
|
|
2369
|
+
const abortController = new AbortController();
|
|
2370
|
+
|
|
2371
|
+
const run = startOpenclawClawlingGateway({
|
|
2372
|
+
cfg: {},
|
|
2373
|
+
account: baseAccount(),
|
|
2374
|
+
abortSignal: abortController.signal,
|
|
2375
|
+
setStatus: vi.fn(),
|
|
2376
|
+
getStatus: vi.fn(() => ({ connected: false, configured: true, running: true })),
|
|
2377
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
2378
|
+
transport,
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
await Promise.resolve();
|
|
2382
|
+
transport.emitInbound(
|
|
2383
|
+
JSON.stringify({
|
|
2384
|
+
version: "2",
|
|
2385
|
+
event: "connect.challenge",
|
|
2386
|
+
trace_id: "challenge",
|
|
2387
|
+
emitted_at: Date.now(),
|
|
2388
|
+
payload: { nonce: "nonce" },
|
|
2389
|
+
}),
|
|
2390
|
+
);
|
|
2391
|
+
const connectFrame = transport.sent
|
|
2392
|
+
.map((raw) => JSON.parse(raw))
|
|
2393
|
+
.find((env) => env.event === "connect");
|
|
2394
|
+
transport.emitInbound(
|
|
2395
|
+
JSON.stringify({
|
|
2396
|
+
version: "2",
|
|
2397
|
+
event: "hello-ok",
|
|
2398
|
+
trace_id: connectFrame.trace_id,
|
|
2399
|
+
emitted_at: Date.now(),
|
|
2400
|
+
payload: {},
|
|
2401
|
+
}),
|
|
2402
|
+
);
|
|
2403
|
+
await Promise.resolve();
|
|
2404
|
+
transport.emitInbound(
|
|
2405
|
+
JSON.stringify({
|
|
2406
|
+
version: "2",
|
|
2407
|
+
event: "message.done",
|
|
2408
|
+
trace_id: "done-1",
|
|
2409
|
+
emitted_at: Date.now(),
|
|
2410
|
+
chat_id: "chat-1",
|
|
2411
|
+
chat_type: "direct",
|
|
2412
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
2413
|
+
payload: {
|
|
2414
|
+
message_id: "stream-1",
|
|
2415
|
+
fragments: [{ kind: "text", text: "completed stream" }],
|
|
2416
|
+
streaming: {
|
|
2417
|
+
status: "done",
|
|
2418
|
+
sequence: 1,
|
|
2419
|
+
mutation_policy: "append_text_only",
|
|
2420
|
+
started_at: null,
|
|
2421
|
+
completed_at: Date.now(),
|
|
2422
|
+
},
|
|
2423
|
+
},
|
|
2424
|
+
}),
|
|
2425
|
+
);
|
|
2426
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2427
|
+
abortController.abort();
|
|
2428
|
+
await run;
|
|
2429
|
+
|
|
2430
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
365
2431
|
});
|
|
366
2432
|
});
|
|
367
2433
|
|
|
@@ -406,6 +2472,11 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
|
406
2472
|
withReplyDispatcher,
|
|
407
2473
|
dispatchReplyFromConfig,
|
|
408
2474
|
},
|
|
2475
|
+
turn: {
|
|
2476
|
+
buildContext: vi.fn((params) =>
|
|
2477
|
+
buildTestInboundContext(params as Parameters<typeof buildTestInboundContext>[0]),
|
|
2478
|
+
),
|
|
2479
|
+
},
|
|
409
2480
|
media: {
|
|
410
2481
|
fetchRemoteMedia: vi.fn(),
|
|
411
2482
|
saveMediaBuffer: vi.fn(),
|
|
@@ -462,11 +2533,14 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
|
462
2533
|
payload: { nonce: "n" },
|
|
463
2534
|
}),
|
|
464
2535
|
);
|
|
2536
|
+
const connectFrame = transport.sent
|
|
2537
|
+
.map((raw) => JSON.parse(raw))
|
|
2538
|
+
.find((env) => env.event === "connect");
|
|
465
2539
|
transport.emitInbound(
|
|
466
2540
|
JSON.stringify({
|
|
467
2541
|
version: "2",
|
|
468
2542
|
event: "hello-ok",
|
|
469
|
-
trace_id:
|
|
2543
|
+
trace_id: connectFrame.trace_id,
|
|
470
2544
|
emitted_at: Date.now(),
|
|
471
2545
|
payload: {},
|
|
472
2546
|
}),
|
|
@@ -482,7 +2556,7 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
|
482
2556
|
chat_id: "chat-1",
|
|
483
2557
|
chat_type: "direct",
|
|
484
2558
|
to: { id: "u", type: "direct" },
|
|
485
|
-
sender: {
|
|
2559
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
486
2560
|
payload: {
|
|
487
2561
|
message_id: "m-fail",
|
|
488
2562
|
message_mode: "normal",
|
|
@@ -512,6 +2586,8 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
|
512
2586
|
expect(logError).toHaveBeenCalledWith(
|
|
513
2587
|
expect.stringContaining("openclaw-clawchat dispatch failed msg=m-fail"),
|
|
514
2588
|
);
|
|
2589
|
+
const sentEvents = transport.sent.map((wire) => JSON.parse(wire) as { event: string });
|
|
2590
|
+
expect(sentEvents.filter((event) => event.event === "message.send")).toEqual([]);
|
|
515
2591
|
});
|
|
516
2592
|
});
|
|
517
2593
|
|
|
@@ -569,11 +2645,14 @@ describe("openclaw-clawchat runtime connect flow", () => {
|
|
|
569
2645
|
payload: { nonce: "n1" },
|
|
570
2646
|
}),
|
|
571
2647
|
);
|
|
2648
|
+
const connectFrame = transport.sent
|
|
2649
|
+
.map((raw) => JSON.parse(raw))
|
|
2650
|
+
.find((env) => env.event === "connect");
|
|
572
2651
|
transport.emitInbound(
|
|
573
2652
|
JSON.stringify({
|
|
574
2653
|
version: "2",
|
|
575
2654
|
event: "hello-ok",
|
|
576
|
-
trace_id:
|
|
2655
|
+
trace_id: connectFrame.trace_id,
|
|
577
2656
|
emitted_at: Date.now(),
|
|
578
2657
|
payload: {},
|
|
579
2658
|
}),
|