@clawling/clawchat-plugin-openclaw 2026.5.12-28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +64 -0
- package/README.md +227 -0
- package/dist/index.js +20 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +263 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +66 -0
- package/dist/src/channel.setup.js +119 -0
- package/dist/src/clawchat-memory.js +403 -0
- package/dist/src/clawchat-metadata.js +310 -0
- package/dist/src/client.js +35 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +274 -0
- package/dist/src/group-message-coalescer.js +119 -0
- package/dist/src/inbound.js +170 -0
- package/dist/src/llm-context-debug.js +86 -0
- package/dist/src/login.runtime.js +204 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +146 -0
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +628 -0
- package/dist/src/plugin-prompts.js +89 -0
- package/dist/src/profile-prompt.js +269 -0
- package/dist/src/profile-sync.js +110 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +33 -0
- package/dist/src/reply-dispatcher.js +422 -0
- package/dist/src/runtime.js +1254 -0
- package/dist/src/storage.js +525 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/terminal-send.js +36 -0
- package/dist/src/tools-schema.js +208 -0
- package/dist/src/tools.js +920 -0
- 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 +24 -0
- package/openclaw.plugin.json +169 -0
- package/package.json +80 -0
- package/prompts/default-group-bio.md +19 -0
- package/prompts/default-owner-behavior.md +27 -0
- package/prompts/platform.md +13 -0
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +91 -0
- package/src/api-client.test.ts +827 -0
- package/src/api-client.ts +414 -0
- package/src/api-types.ts +146 -0
- package/src/channel.outbound.test.ts +433 -0
- package/src/channel.setup.ts +145 -0
- package/src/channel.test.ts +262 -0
- package/src/channel.ts +81 -0
- package/src/clawchat-memory.test.ts +480 -0
- package/src/clawchat-memory.ts +533 -0
- package/src/clawchat-metadata.test.ts +477 -0
- package/src/clawchat-metadata.ts +429 -0
- package/src/client.test.ts +169 -0
- package/src/client.ts +56 -0
- package/src/commands.test.ts +39 -0
- package/src/commands.ts +41 -0
- package/src/config.test.ts +344 -0
- package/src/config.ts +404 -0
- package/src/group-message-coalescer.test.ts +237 -0
- package/src/group-message-coalescer.ts +171 -0
- package/src/inbound.test.ts +508 -0
- package/src/inbound.ts +278 -0
- package/src/llm-context-debug.test.ts +55 -0
- package/src/llm-context-debug.ts +139 -0
- package/src/login.runtime.test.ts +737 -0
- package/src/login.runtime.ts +277 -0
- package/src/manifest.test.ts +352 -0
- package/src/media-runtime.test.ts +207 -0
- package/src/media-runtime.ts +152 -0
- package/src/message-mapper.test.ts +201 -0
- package/src/message-mapper.ts +174 -0
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +1269 -0
- package/src/outbound.ts +803 -0
- package/src/plugin-entry.test.ts +38 -0
- package/src/plugin-prompts.test.ts +94 -0
- package/src/plugin-prompts.ts +107 -0
- package/src/profile-prompt.test.ts +274 -0
- package/src/profile-prompt.ts +351 -0
- package/src/profile-sync.test.ts +539 -0
- package/src/profile-sync.ts +191 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +39 -0
- package/src/protocol.ts +42 -0
- package/src/reply-dispatcher.test.ts +1324 -0
- package/src/reply-dispatcher.ts +555 -0
- package/src/runtime.test.ts +4719 -0
- package/src/runtime.ts +1493 -0
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +560 -0
- package/src/storage.ts +807 -0
- package/src/terminal-send.test.ts +81 -0
- package/src/terminal-send.ts +56 -0
- package/src/tools-schema.ts +337 -0
- package/src/tools.test.ts +933 -0
- package/src/tools.ts +1185 -0
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1217 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { MockTransport } from "./mock-transport.ts";
|
|
3
|
+
import { createClawChatClient, createWebSocketTransport } from "./ws-client.ts";
|
|
4
|
+
import { AuthError, EVENT, ProtocolError, TransportError, type Envelope } from "./protocol-types.ts";
|
|
5
|
+
|
|
6
|
+
function decodeSent(transport: MockTransport) {
|
|
7
|
+
return transport.sent.map((raw) => JSON.parse(raw) as Envelope);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function connectReady(transport: MockTransport, client: ReturnType<typeof createClawChatClient>) {
|
|
11
|
+
const connected = client.connect();
|
|
12
|
+
const connectFrame = await completeHandshake(transport);
|
|
13
|
+
await connected;
|
|
14
|
+
return connectFrame;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function completeHandshake(transport: MockTransport) {
|
|
18
|
+
transport.emitInbound(JSON.stringify({
|
|
19
|
+
version: "2",
|
|
20
|
+
event: "connect.challenge",
|
|
21
|
+
trace_id: "challenge-1",
|
|
22
|
+
emitted_at: Date.now(),
|
|
23
|
+
payload: { nonce: "nonce-1" },
|
|
24
|
+
}));
|
|
25
|
+
const connectFrame = decodeSent(transport).find((env) => env.event === EVENT.CONNECT)!;
|
|
26
|
+
transport.emitInbound(JSON.stringify({
|
|
27
|
+
version: "2",
|
|
28
|
+
event: "hello-ok",
|
|
29
|
+
trace_id: connectFrame.trace_id,
|
|
30
|
+
emitted_at: Date.now(),
|
|
31
|
+
payload: { device_id: "agent-1", delivery_mode: "device_replay" },
|
|
32
|
+
}));
|
|
33
|
+
return connectFrame;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("createWebSocketTransport", () => {
|
|
37
|
+
it("adapts a WebSocket constructor to the local transport interface", async () => {
|
|
38
|
+
class FakeWebSocket {
|
|
39
|
+
static instances: FakeWebSocket[] = [];
|
|
40
|
+
readonly listeners = new Map<string, Array<(event?: unknown) => void>>();
|
|
41
|
+
readonly send = vi.fn();
|
|
42
|
+
readonly close = vi.fn((code?: number, reason?: string) => {
|
|
43
|
+
this.emit("close", { code, reason });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
constructor(readonly url: string) {
|
|
47
|
+
FakeWebSocket.instances.push(this);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
addEventListener(type: string, listener: (event?: unknown) => void): void {
|
|
51
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
emit(type: string, event?: unknown): void {
|
|
55
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const transport = createWebSocketTransport(FakeWebSocket);
|
|
60
|
+
const onOpen = vi.fn();
|
|
61
|
+
const onMessage = vi.fn();
|
|
62
|
+
const onClose = vi.fn();
|
|
63
|
+
const onError = vi.fn();
|
|
64
|
+
const connected = transport.connect("ws://test", { onOpen, onMessage, onClose, onError });
|
|
65
|
+
const socket = FakeWebSocket.instances[0]!;
|
|
66
|
+
|
|
67
|
+
expect(transport.state).toBe("connecting");
|
|
68
|
+
socket.emit("open");
|
|
69
|
+
await connected;
|
|
70
|
+
expect(transport.state).toBe("open");
|
|
71
|
+
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
72
|
+
|
|
73
|
+
transport.send("frame-1");
|
|
74
|
+
expect(socket.send).toHaveBeenCalledWith("frame-1");
|
|
75
|
+
|
|
76
|
+
socket.emit("message", { data: "frame-2" });
|
|
77
|
+
expect(onMessage).toHaveBeenCalledWith("frame-2");
|
|
78
|
+
|
|
79
|
+
transport.close(1000, "done");
|
|
80
|
+
expect(onClose).toHaveBeenCalledWith(1000, "done");
|
|
81
|
+
expect(transport.state).toBe("closed");
|
|
82
|
+
expect(onError).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects when the socket closes before opening", async () => {
|
|
86
|
+
class FakeWebSocket {
|
|
87
|
+
static instances: FakeWebSocket[] = [];
|
|
88
|
+
readonly listeners = new Map<string, Array<(event?: unknown) => void>>();
|
|
89
|
+
readonly send = vi.fn();
|
|
90
|
+
readonly close = vi.fn();
|
|
91
|
+
|
|
92
|
+
constructor(readonly url: string) {
|
|
93
|
+
FakeWebSocket.instances.push(this);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
addEventListener(type: string, listener: (event?: unknown) => void): void {
|
|
97
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
emit(type: string, event?: unknown): void {
|
|
101
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const transport = createWebSocketTransport(FakeWebSocket);
|
|
106
|
+
const connected = transport.connect("ws://test", {
|
|
107
|
+
onOpen: vi.fn(),
|
|
108
|
+
onMessage: vi.fn(),
|
|
109
|
+
onClose: vi.fn(),
|
|
110
|
+
onError: vi.fn(),
|
|
111
|
+
});
|
|
112
|
+
FakeWebSocket.instances[0]!.emit("close", { code: 1006, reason: "dial failed" });
|
|
113
|
+
|
|
114
|
+
await expect(connected).rejects.toThrow("dial failed");
|
|
115
|
+
expect(transport.state).toBe("closed");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("createClawChatClient", () => {
|
|
120
|
+
it("sends the msghub connect payload from the protocol reference", async () => {
|
|
121
|
+
const transport = new MockTransport();
|
|
122
|
+
const client = createClawChatClient({
|
|
123
|
+
url: "ws://test",
|
|
124
|
+
token: "token-1",
|
|
125
|
+
deviceId: "agent-1",
|
|
126
|
+
transport,
|
|
127
|
+
traceIdFactory: () => "trace-connect",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const connectFrame = await connectReady(transport, client);
|
|
131
|
+
expect(connectFrame).toMatchObject({
|
|
132
|
+
version: "2",
|
|
133
|
+
event: "connect",
|
|
134
|
+
trace_id: "trace-connect",
|
|
135
|
+
payload: {
|
|
136
|
+
token: "token-1",
|
|
137
|
+
nonce: "nonce-1",
|
|
138
|
+
device_id: "agent-1",
|
|
139
|
+
capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(client.state).toBe("connected");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("answers JSON ping with JSON pong", async () => {
|
|
146
|
+
const transport = new MockTransport();
|
|
147
|
+
const client = createClawChatClient({
|
|
148
|
+
url: "ws://test",
|
|
149
|
+
token: "token-1",
|
|
150
|
+
deviceId: "agent-1",
|
|
151
|
+
transport,
|
|
152
|
+
traceIdFactory: () => "trace-1",
|
|
153
|
+
});
|
|
154
|
+
await connectReady(transport, client);
|
|
155
|
+
transport.sent.length = 0;
|
|
156
|
+
|
|
157
|
+
transport.emitInbound(JSON.stringify({
|
|
158
|
+
version: "2",
|
|
159
|
+
event: "ping",
|
|
160
|
+
trace_id: "ping-1",
|
|
161
|
+
emitted_at: 1776162600000,
|
|
162
|
+
payload: {},
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
expect(decodeSent(transport)).toContainEqual(expect.objectContaining({
|
|
166
|
+
event: "pong",
|
|
167
|
+
trace_id: "ping-1",
|
|
168
|
+
emitted_at: 1776162600000,
|
|
169
|
+
payload: {},
|
|
170
|
+
}));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("emits hello-ok with resolved device id and delivery mode", async () => {
|
|
174
|
+
const transport = new MockTransport();
|
|
175
|
+
const client = createClawChatClient({
|
|
176
|
+
url: "ws://test",
|
|
177
|
+
token: "token-1",
|
|
178
|
+
deviceId: "agent-configured",
|
|
179
|
+
transport,
|
|
180
|
+
traceIdFactory: () => "trace-connect",
|
|
181
|
+
heartbeat: { enabled: false },
|
|
182
|
+
});
|
|
183
|
+
const helloOk = vi.fn();
|
|
184
|
+
client.on("hello:ok", helloOk);
|
|
185
|
+
|
|
186
|
+
await connectReady(transport, client);
|
|
187
|
+
|
|
188
|
+
expect(helloOk).toHaveBeenCalledWith(expect.objectContaining({
|
|
189
|
+
event: "hello-ok",
|
|
190
|
+
payload: { device_id: "agent-1", delivery_mode: "device_replay" },
|
|
191
|
+
}));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("emits metadata invalidation events", async () => {
|
|
195
|
+
const transport = new MockTransport();
|
|
196
|
+
const client = createClawChatClient({
|
|
197
|
+
url: "ws://test",
|
|
198
|
+
token: "token-1",
|
|
199
|
+
deviceId: "agent-1",
|
|
200
|
+
transport,
|
|
201
|
+
traceIdFactory: () => "trace-1",
|
|
202
|
+
heartbeat: { enabled: false },
|
|
203
|
+
});
|
|
204
|
+
const invalidated = vi.fn();
|
|
205
|
+
client.on("metadata:invalidated", invalidated);
|
|
206
|
+
await connectReady(transport, client);
|
|
207
|
+
|
|
208
|
+
transport.emitInbound(JSON.stringify({
|
|
209
|
+
version: "2",
|
|
210
|
+
event: "chat.metadata.invalidated",
|
|
211
|
+
trace_id: "meta-1",
|
|
212
|
+
emitted_at: Date.now(),
|
|
213
|
+
chat_id: "group-1",
|
|
214
|
+
chat_type: "group",
|
|
215
|
+
payload: { scope: ["title"], version: 7, updated_at: 1776163000000 },
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
expect(invalidated).toHaveBeenCalledWith(expect.objectContaining({
|
|
219
|
+
event: "chat.metadata.invalidated",
|
|
220
|
+
chat_id: "group-1",
|
|
221
|
+
payload: { scope: ["title"], version: 7, updated_at: 1776163000000 },
|
|
222
|
+
}));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("tolerates presence events without presence-specific helper events", async () => {
|
|
226
|
+
const transport = new MockTransport();
|
|
227
|
+
const client = createClawChatClient({
|
|
228
|
+
url: "ws://test",
|
|
229
|
+
token: "token-1",
|
|
230
|
+
deviceId: "agent-1",
|
|
231
|
+
transport,
|
|
232
|
+
traceIdFactory: () => "trace-1",
|
|
233
|
+
heartbeat: { enabled: false },
|
|
234
|
+
});
|
|
235
|
+
const error = vi.fn();
|
|
236
|
+
const presence = vi.fn();
|
|
237
|
+
const presenceSnapshot = vi.fn();
|
|
238
|
+
const presenceUpdate = vi.fn();
|
|
239
|
+
client.on("error", error);
|
|
240
|
+
client.on("presence", presence);
|
|
241
|
+
client.on("presence:snapshot", presenceSnapshot);
|
|
242
|
+
client.on("presence:update", presenceUpdate);
|
|
243
|
+
await connectReady(transport, client);
|
|
244
|
+
|
|
245
|
+
for (const event of ["presence.snapshot", "presence.update"]) {
|
|
246
|
+
transport.emitInbound(JSON.stringify({
|
|
247
|
+
version: "2",
|
|
248
|
+
event,
|
|
249
|
+
trace_id: `trace-${event}`,
|
|
250
|
+
emitted_at: Date.now(),
|
|
251
|
+
payload: { user_id: "usr_bob", online: true, last_seen_at: "2026-05-13T12:35:00Z" },
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
expect(error).not.toHaveBeenCalled();
|
|
256
|
+
expect(presence).not.toHaveBeenCalled();
|
|
257
|
+
expect(presenceSnapshot).not.toHaveBeenCalled();
|
|
258
|
+
expect(presenceUpdate).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("emits materialized messages through the message event", async () => {
|
|
262
|
+
const transport = new MockTransport();
|
|
263
|
+
const client = createClawChatClient({
|
|
264
|
+
url: "ws://test",
|
|
265
|
+
token: "token-1",
|
|
266
|
+
deviceId: "agent-1",
|
|
267
|
+
transport,
|
|
268
|
+
traceIdFactory: () => "trace-1",
|
|
269
|
+
});
|
|
270
|
+
const messages: Envelope[] = [];
|
|
271
|
+
client.on("message", (env) => messages.push(env));
|
|
272
|
+
await connectReady(transport, client);
|
|
273
|
+
|
|
274
|
+
for (const event of [EVENT.MESSAGE_SEND, EVENT.MESSAGE_REPLY, EVENT.MESSAGE_DONE]) {
|
|
275
|
+
transport.emitInbound(JSON.stringify({
|
|
276
|
+
version: "2",
|
|
277
|
+
event,
|
|
278
|
+
trace_id: `trace-${event}`,
|
|
279
|
+
emitted_at: Date.now(),
|
|
280
|
+
chat_id: "chat-1",
|
|
281
|
+
chat_type: "direct",
|
|
282
|
+
sender: { id: "user-1", type: "direct", nick_name: "User" },
|
|
283
|
+
payload: event === EVENT.MESSAGE_DONE
|
|
284
|
+
? {
|
|
285
|
+
message_id: `msg-${event}`,
|
|
286
|
+
fragments: [{ kind: "text", text: "done text" }],
|
|
287
|
+
streaming: { status: "done", sequence: 1, mutation_policy: "append_text_only", started_at: null, completed_at: Date.now() },
|
|
288
|
+
}
|
|
289
|
+
: {
|
|
290
|
+
message_id: `msg-${event}`,
|
|
291
|
+
message_mode: "normal",
|
|
292
|
+
message: {
|
|
293
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
294
|
+
context: { mentions: [], reply: null },
|
|
295
|
+
streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
expect(messages.map((env) => env.event)).toEqual([
|
|
302
|
+
EVENT.MESSAGE_SEND,
|
|
303
|
+
EVENT.MESSAGE_REPLY,
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("sends typing.update with is_typing", async () => {
|
|
308
|
+
const transport = new MockTransport();
|
|
309
|
+
const client = createClawChatClient({
|
|
310
|
+
url: "ws://test",
|
|
311
|
+
token: "token-1",
|
|
312
|
+
deviceId: "agent-1",
|
|
313
|
+
transport,
|
|
314
|
+
traceIdFactory: () => "trace-typing",
|
|
315
|
+
});
|
|
316
|
+
await connectReady(transport, client);
|
|
317
|
+
transport.sent.length = 0;
|
|
318
|
+
|
|
319
|
+
client.typing("chat-1", true);
|
|
320
|
+
|
|
321
|
+
expect(decodeSent(transport)).toContainEqual(expect.objectContaining({
|
|
322
|
+
event: "typing.update",
|
|
323
|
+
chat_id: "chat-1",
|
|
324
|
+
payload: { is_typing: true },
|
|
325
|
+
}));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("resolves ackable sends from matching message.ack", async () => {
|
|
329
|
+
const transport = new MockTransport();
|
|
330
|
+
const client = createClawChatClient({
|
|
331
|
+
url: "ws://test",
|
|
332
|
+
token: "token-1",
|
|
333
|
+
deviceId: "agent-1",
|
|
334
|
+
transport,
|
|
335
|
+
traceIdFactory: vi.fn()
|
|
336
|
+
.mockReturnValueOnce("trace-connect")
|
|
337
|
+
.mockReturnValueOnce("trace-send"),
|
|
338
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
339
|
+
});
|
|
340
|
+
await connectReady(transport, client);
|
|
341
|
+
|
|
342
|
+
const send = client.sendAckableEnvelope({
|
|
343
|
+
eventName: "message.send",
|
|
344
|
+
chatId: "chat-1",
|
|
345
|
+
payload: {
|
|
346
|
+
message_mode: "normal",
|
|
347
|
+
message: {
|
|
348
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
349
|
+
context: { mentions: [], reply: null },
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
transport.emitInbound(JSON.stringify({
|
|
354
|
+
version: "2",
|
|
355
|
+
event: "message.ack",
|
|
356
|
+
trace_id: "trace-send",
|
|
357
|
+
emitted_at: Date.now(),
|
|
358
|
+
chat_id: "chat-1",
|
|
359
|
+
payload: { message_id: "msg-1", accepted_at: 1776162600000 },
|
|
360
|
+
}));
|
|
361
|
+
|
|
362
|
+
await expect(send).resolves.toMatchObject({ payload: { message_id: "msg-1" } });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("rejects ackable sends from matching message.error", async () => {
|
|
366
|
+
vi.useFakeTimers();
|
|
367
|
+
try {
|
|
368
|
+
const transport = new MockTransport();
|
|
369
|
+
const client = createClawChatClient({
|
|
370
|
+
url: "ws://test",
|
|
371
|
+
token: "token-1",
|
|
372
|
+
deviceId: "agent-1",
|
|
373
|
+
transport,
|
|
374
|
+
traceIdFactory: vi.fn()
|
|
375
|
+
.mockReturnValueOnce("trace-connect")
|
|
376
|
+
.mockReturnValueOnce("trace-send"),
|
|
377
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
378
|
+
heartbeat: { enabled: false },
|
|
379
|
+
});
|
|
380
|
+
await connectReady(transport, client);
|
|
381
|
+
|
|
382
|
+
const send = client.sendAckableEnvelope({
|
|
383
|
+
eventName: "message.send",
|
|
384
|
+
chatId: "missing-chat",
|
|
385
|
+
payload: {
|
|
386
|
+
message_mode: "normal",
|
|
387
|
+
message: {
|
|
388
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
389
|
+
context: { mentions: [], reply: null },
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
transport.emitInbound(JSON.stringify({
|
|
394
|
+
version: "2",
|
|
395
|
+
event: "message.error",
|
|
396
|
+
trace_id: "trace-send",
|
|
397
|
+
emitted_at: Date.now(),
|
|
398
|
+
chat_id: "missing-chat",
|
|
399
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
400
|
+
}));
|
|
401
|
+
|
|
402
|
+
await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
403
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
404
|
+
} finally {
|
|
405
|
+
vi.useRealTimers();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("ignores unmatched message.error without rejecting unrelated pending sends", async () => {
|
|
410
|
+
vi.useFakeTimers();
|
|
411
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
412
|
+
try {
|
|
413
|
+
const transport = new MockTransport();
|
|
414
|
+
const client = createClawChatClient({
|
|
415
|
+
url: "ws://test",
|
|
416
|
+
token: "token-1",
|
|
417
|
+
deviceId: "agent-1",
|
|
418
|
+
transport,
|
|
419
|
+
traceIdFactory: vi.fn()
|
|
420
|
+
.mockReturnValueOnce("trace-connect")
|
|
421
|
+
.mockReturnValueOnce("trace-send"),
|
|
422
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
423
|
+
heartbeat: { enabled: false },
|
|
424
|
+
});
|
|
425
|
+
await connectReady(transport, client);
|
|
426
|
+
|
|
427
|
+
const send = client.sendAckableEnvelope({
|
|
428
|
+
eventName: "message.send",
|
|
429
|
+
chatId: "chat-1",
|
|
430
|
+
payload: {
|
|
431
|
+
message_mode: "normal",
|
|
432
|
+
message: {
|
|
433
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
434
|
+
context: { mentions: [], reply: null },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
transport.emitInbound(JSON.stringify({
|
|
439
|
+
version: "2",
|
|
440
|
+
event: "message.error",
|
|
441
|
+
trace_id: "trace-other",
|
|
442
|
+
emitted_at: Date.now(),
|
|
443
|
+
chat_id: "missing-chat",
|
|
444
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
448
|
+
|
|
449
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
450
|
+
const pendingOutcome = await Promise.race([
|
|
451
|
+
send.then(() => "resolved", (err) => err),
|
|
452
|
+
Promise.resolve("pending"),
|
|
453
|
+
]);
|
|
454
|
+
expect(pendingOutcome).toBe("pending");
|
|
455
|
+
|
|
456
|
+
transport.emitInbound(JSON.stringify({
|
|
457
|
+
version: "2",
|
|
458
|
+
event: "message.ack",
|
|
459
|
+
trace_id: "trace-send",
|
|
460
|
+
emitted_at: Date.now(),
|
|
461
|
+
chat_id: "chat-1",
|
|
462
|
+
payload: { message_id: "msg-1", accepted_at: 1776162600000 },
|
|
463
|
+
}));
|
|
464
|
+
await expect(send).resolves.toMatchObject({ payload: { message_id: "msg-1" } });
|
|
465
|
+
} finally {
|
|
466
|
+
warn.mockRestore();
|
|
467
|
+
vi.useRealTimers();
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("resends ackable sends on timeout when explicitly enabled", async () => {
|
|
472
|
+
vi.useFakeTimers();
|
|
473
|
+
try {
|
|
474
|
+
const transport = new MockTransport();
|
|
475
|
+
const client = createClawChatClient({
|
|
476
|
+
url: "ws://test",
|
|
477
|
+
token: "token-1",
|
|
478
|
+
deviceId: "agent-1",
|
|
479
|
+
transport,
|
|
480
|
+
traceIdFactory: vi.fn()
|
|
481
|
+
.mockReturnValueOnce("trace-connect")
|
|
482
|
+
.mockReturnValueOnce("trace-send"),
|
|
483
|
+
ack: { timeout: 50, autoResendOnTimeout: true },
|
|
484
|
+
heartbeat: { enabled: false },
|
|
485
|
+
});
|
|
486
|
+
await connectReady(transport, client);
|
|
487
|
+
transport.sent.length = 0;
|
|
488
|
+
|
|
489
|
+
const send = client.sendAckableEnvelope({
|
|
490
|
+
eventName: "message.send",
|
|
491
|
+
chatId: "chat-1",
|
|
492
|
+
payload: {
|
|
493
|
+
message_mode: "normal",
|
|
494
|
+
message: {
|
|
495
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
496
|
+
context: { mentions: [], reply: null },
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
501
|
+
expect(transport.sent).toHaveLength(2);
|
|
502
|
+
|
|
503
|
+
transport.emitInbound(JSON.stringify({
|
|
504
|
+
version: "2",
|
|
505
|
+
event: "message.ack",
|
|
506
|
+
trace_id: "trace-send",
|
|
507
|
+
emitted_at: Date.now(),
|
|
508
|
+
chat_id: "chat-1",
|
|
509
|
+
payload: { message_id: "msg-1", accepted_at: 1776162600000 },
|
|
510
|
+
}));
|
|
511
|
+
await expect(send).resolves.toMatchObject({ payload: { message_id: "msg-1" } });
|
|
512
|
+
} finally {
|
|
513
|
+
vi.useRealTimers();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("keeps reconnecting when the initial transport connect fails", async () => {
|
|
518
|
+
vi.useFakeTimers();
|
|
519
|
+
try {
|
|
520
|
+
class FailingOnceTransport extends MockTransport {
|
|
521
|
+
attempts = 0;
|
|
522
|
+
|
|
523
|
+
override async connect(url: string, handlers: Parameters<MockTransport["connect"]>[1]): Promise<void> {
|
|
524
|
+
this.attempts += 1;
|
|
525
|
+
if (this.attempts === 1) throw new Error("dial failed");
|
|
526
|
+
return await super.connect(url, handlers);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const transport = new FailingOnceTransport();
|
|
530
|
+
const client = createClawChatClient({
|
|
531
|
+
url: "ws://test",
|
|
532
|
+
token: "token-1",
|
|
533
|
+
deviceId: "agent-1",
|
|
534
|
+
transport,
|
|
535
|
+
traceIdFactory: () => "trace-1",
|
|
536
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 2, jitterRatio: 0 },
|
|
537
|
+
heartbeat: { enabled: false },
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const connected = client.connect();
|
|
541
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
542
|
+
expect(transport.attempts).toBe(2);
|
|
543
|
+
await completeHandshake(transport);
|
|
544
|
+
|
|
545
|
+
await expect(connected).resolves.toBeUndefined();
|
|
546
|
+
} finally {
|
|
547
|
+
vi.useRealTimers();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("continues reconnecting after consecutive transport connect failures", async () => {
|
|
552
|
+
vi.useFakeTimers();
|
|
553
|
+
try {
|
|
554
|
+
class FailingTwiceTransport extends MockTransport {
|
|
555
|
+
attempts = 0;
|
|
556
|
+
|
|
557
|
+
override async connect(url: string, handlers: Parameters<MockTransport["connect"]>[1]): Promise<void> {
|
|
558
|
+
this.attempts += 1;
|
|
559
|
+
if (this.attempts <= 2) throw new Error(`dial failed ${this.attempts}`);
|
|
560
|
+
return await super.connect(url, handlers);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const transport = new FailingTwiceTransport();
|
|
564
|
+
const client = createClawChatClient({
|
|
565
|
+
url: "ws://test",
|
|
566
|
+
token: "token-1",
|
|
567
|
+
deviceId: "agent-1",
|
|
568
|
+
transport,
|
|
569
|
+
traceIdFactory: () => "trace-1",
|
|
570
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 3, jitterRatio: 0 },
|
|
571
|
+
heartbeat: { enabled: false },
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const connected = client.connect();
|
|
575
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
576
|
+
expect(transport.attempts).toBe(2);
|
|
577
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
578
|
+
expect(transport.attempts).toBe(3);
|
|
579
|
+
await completeHandshake(transport);
|
|
580
|
+
|
|
581
|
+
await expect(connected).resolves.toBeUndefined();
|
|
582
|
+
} finally {
|
|
583
|
+
vi.useRealTimers();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("queues raw sends while reconnecting and flushes them after reconnect", async () => {
|
|
588
|
+
vi.useFakeTimers();
|
|
589
|
+
try {
|
|
590
|
+
const transport = new MockTransport();
|
|
591
|
+
const client = createClawChatClient({
|
|
592
|
+
url: "ws://test",
|
|
593
|
+
token: "token-1",
|
|
594
|
+
deviceId: "agent-1",
|
|
595
|
+
transport,
|
|
596
|
+
traceIdFactory: vi.fn()
|
|
597
|
+
.mockReturnValueOnce("trace-connect-1")
|
|
598
|
+
.mockReturnValueOnce("trace-queued")
|
|
599
|
+
.mockReturnValueOnce("trace-connect-2"),
|
|
600
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 2, jitterRatio: 0 },
|
|
601
|
+
heartbeat: { enabled: false },
|
|
602
|
+
});
|
|
603
|
+
await connectReady(transport, client);
|
|
604
|
+
transport.sent.length = 0;
|
|
605
|
+
|
|
606
|
+
transport.close(1006, "network lost");
|
|
607
|
+
expect(client.state).toBe("reconnecting");
|
|
608
|
+
|
|
609
|
+
expect(() => client.emitRaw("message.created", { message_id: "msg-1" }, { chat_id: "chat-1" })).not.toThrow();
|
|
610
|
+
expect(transport.sent).toHaveLength(0);
|
|
611
|
+
|
|
612
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
613
|
+
await completeHandshake(transport);
|
|
614
|
+
|
|
615
|
+
expect(decodeSent(transport)).toEqual([
|
|
616
|
+
expect.objectContaining({ event: EVENT.CONNECT, trace_id: "trace-connect-2" }),
|
|
617
|
+
expect.objectContaining({
|
|
618
|
+
event: "message.created",
|
|
619
|
+
trace_id: "trace-queued",
|
|
620
|
+
chat_id: "chat-1",
|
|
621
|
+
payload: { message_id: "msg-1" },
|
|
622
|
+
}),
|
|
623
|
+
]);
|
|
624
|
+
} finally {
|
|
625
|
+
vi.useRealTimers();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("queues raw sends during reconnect handshake until hello-ok", async () => {
|
|
630
|
+
vi.useFakeTimers();
|
|
631
|
+
try {
|
|
632
|
+
const transport = new MockTransport();
|
|
633
|
+
const client = createClawChatClient({
|
|
634
|
+
url: "ws://test",
|
|
635
|
+
token: "token-1",
|
|
636
|
+
deviceId: "agent-1",
|
|
637
|
+
transport,
|
|
638
|
+
traceIdFactory: vi.fn()
|
|
639
|
+
.mockReturnValueOnce("trace-connect-1")
|
|
640
|
+
.mockReturnValueOnce("trace-queued")
|
|
641
|
+
.mockReturnValueOnce("trace-connect-2"),
|
|
642
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 2, jitterRatio: 0 },
|
|
643
|
+
heartbeat: { enabled: false },
|
|
644
|
+
});
|
|
645
|
+
await connectReady(transport, client);
|
|
646
|
+
transport.sent.length = 0;
|
|
647
|
+
|
|
648
|
+
transport.close(1006, "network lost");
|
|
649
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
650
|
+
expect(client.state).toBe("challenging");
|
|
651
|
+
|
|
652
|
+
expect(() => client.emitRaw("message.created", { message_id: "msg-1" }, { chat_id: "chat-1" })).not.toThrow();
|
|
653
|
+
expect(transport.sent).toHaveLength(0);
|
|
654
|
+
|
|
655
|
+
await completeHandshake(transport);
|
|
656
|
+
|
|
657
|
+
expect(decodeSent(transport)).toEqual([
|
|
658
|
+
expect.objectContaining({ event: EVENT.CONNECT, trace_id: "trace-connect-2" }),
|
|
659
|
+
expect.objectContaining({
|
|
660
|
+
event: "message.created",
|
|
661
|
+
trace_id: "trace-queued",
|
|
662
|
+
chat_id: "chat-1",
|
|
663
|
+
payload: { message_id: "msg-1" },
|
|
664
|
+
}),
|
|
665
|
+
]);
|
|
666
|
+
} finally {
|
|
667
|
+
vi.useRealTimers();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("schedules only one reconnect for websocket error followed by close before open", async () => {
|
|
672
|
+
vi.useFakeTimers();
|
|
673
|
+
try {
|
|
674
|
+
class FakeWebSocket {
|
|
675
|
+
static instances: FakeWebSocket[] = [];
|
|
676
|
+
readonly listeners = new Map<string, Array<(event?: unknown) => void>>();
|
|
677
|
+
readonly send = vi.fn();
|
|
678
|
+
readonly close = vi.fn();
|
|
679
|
+
|
|
680
|
+
constructor(readonly url: string) {
|
|
681
|
+
FakeWebSocket.instances.push(this);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
addEventListener(type: string, listener: (event?: unknown) => void): void {
|
|
685
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
emit(type: string, event?: unknown): void {
|
|
689
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const transport = createWebSocketTransport(FakeWebSocket);
|
|
693
|
+
const client = createClawChatClient({
|
|
694
|
+
url: "ws://test",
|
|
695
|
+
token: "token-1",
|
|
696
|
+
deviceId: "agent-1",
|
|
697
|
+
transport,
|
|
698
|
+
traceIdFactory: () => "trace-1",
|
|
699
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 2, jitterRatio: 0 },
|
|
700
|
+
heartbeat: { enabled: false },
|
|
701
|
+
});
|
|
702
|
+
const scheduled = vi.fn();
|
|
703
|
+
client.on("reconnect:scheduled", scheduled);
|
|
704
|
+
|
|
705
|
+
void client.connect();
|
|
706
|
+
const socket = FakeWebSocket.instances[0]!;
|
|
707
|
+
socket.emit("error", new Error("dial failed"));
|
|
708
|
+
await Promise.resolve();
|
|
709
|
+
socket.emit("close", { code: 1006, reason: "dial failed" });
|
|
710
|
+
|
|
711
|
+
expect(scheduled).toHaveBeenCalledTimes(1);
|
|
712
|
+
} finally {
|
|
713
|
+
vi.useRealTimers();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("ignores stale close events from an old websocket after reconnect starts", async () => {
|
|
718
|
+
vi.useFakeTimers();
|
|
719
|
+
try {
|
|
720
|
+
class FakeWebSocket {
|
|
721
|
+
static instances: FakeWebSocket[] = [];
|
|
722
|
+
readonly listeners = new Map<string, Array<(event?: unknown) => void>>();
|
|
723
|
+
readonly sent: string[] = [];
|
|
724
|
+
readonly send = vi.fn((wire: string) => this.sent.push(wire));
|
|
725
|
+
readonly close = vi.fn();
|
|
726
|
+
|
|
727
|
+
constructor(readonly url: string) {
|
|
728
|
+
FakeWebSocket.instances.push(this);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
addEventListener(type: string, listener: (event?: unknown) => void): void {
|
|
732
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
emit(type: string, event?: unknown): void {
|
|
736
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const transport = createWebSocketTransport(FakeWebSocket);
|
|
740
|
+
const client = createClawChatClient({
|
|
741
|
+
url: "ws://test",
|
|
742
|
+
token: "token-1",
|
|
743
|
+
deviceId: "agent-1",
|
|
744
|
+
transport,
|
|
745
|
+
traceIdFactory: () => "trace-connect",
|
|
746
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 2, jitterRatio: 0 },
|
|
747
|
+
heartbeat: { enabled: false },
|
|
748
|
+
});
|
|
749
|
+
const scheduled = vi.fn();
|
|
750
|
+
client.on("reconnect:scheduled", scheduled);
|
|
751
|
+
const connected = client.connect();
|
|
752
|
+
const oldSocket = FakeWebSocket.instances[0]!;
|
|
753
|
+
oldSocket.emit("open");
|
|
754
|
+
oldSocket.emit("message", { data: JSON.stringify({ version: "2", event: "connect.challenge", trace_id: "challenge-1", emitted_at: Date.now(), payload: { nonce: "nonce-1" } }) });
|
|
755
|
+
oldSocket.emit("message", { data: JSON.stringify({ version: "2", event: "hello-ok", trace_id: "trace-connect", emitted_at: Date.now(), payload: {} }) });
|
|
756
|
+
await connected;
|
|
757
|
+
|
|
758
|
+
oldSocket.emit("close", { code: 1006, reason: "network lost" });
|
|
759
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
760
|
+
const newSocket = FakeWebSocket.instances[1]!;
|
|
761
|
+
newSocket.emit("open");
|
|
762
|
+
expect(transport.state).toBe("open");
|
|
763
|
+
|
|
764
|
+
oldSocket.emit("close", { code: 1006, reason: "late close" });
|
|
765
|
+
|
|
766
|
+
expect(transport.state).toBe("open");
|
|
767
|
+
expect(scheduled).toHaveBeenCalledTimes(1);
|
|
768
|
+
} finally {
|
|
769
|
+
vi.useRealTimers();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("ignores stale messages from a closed websocket during reconnect delay", async () => {
|
|
774
|
+
vi.useFakeTimers();
|
|
775
|
+
try {
|
|
776
|
+
class FakeWebSocket {
|
|
777
|
+
static instances: FakeWebSocket[] = [];
|
|
778
|
+
readonly listeners = new Map<string, Array<(event?: unknown) => void>>();
|
|
779
|
+
readonly sent: string[] = [];
|
|
780
|
+
readonly send = vi.fn((wire: string) => this.sent.push(wire));
|
|
781
|
+
readonly close = vi.fn();
|
|
782
|
+
|
|
783
|
+
constructor(readonly url: string) {
|
|
784
|
+
FakeWebSocket.instances.push(this);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
addEventListener(type: string, listener: (event?: unknown) => void): void {
|
|
788
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
emit(type: string, event?: unknown): void {
|
|
792
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const transport = createWebSocketTransport(FakeWebSocket);
|
|
796
|
+
const client = createClawChatClient({
|
|
797
|
+
url: "ws://test",
|
|
798
|
+
token: "token-1",
|
|
799
|
+
deviceId: "agent-1",
|
|
800
|
+
transport,
|
|
801
|
+
traceIdFactory: () => "trace-connect",
|
|
802
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 2, jitterRatio: 0 },
|
|
803
|
+
heartbeat: { enabled: false },
|
|
804
|
+
});
|
|
805
|
+
const connected = client.connect();
|
|
806
|
+
const oldSocket = FakeWebSocket.instances[0]!;
|
|
807
|
+
oldSocket.emit("open");
|
|
808
|
+
oldSocket.emit("message", { data: JSON.stringify({ version: "2", event: "connect.challenge", trace_id: "challenge-1", emitted_at: Date.now(), payload: { nonce: "nonce-1" } }) });
|
|
809
|
+
oldSocket.emit("message", { data: JSON.stringify({ version: "2", event: "hello-ok", trace_id: "trace-connect", emitted_at: Date.now(), payload: {} }) });
|
|
810
|
+
await connected;
|
|
811
|
+
|
|
812
|
+
oldSocket.emit("close", { code: 1006, reason: "network lost" });
|
|
813
|
+
oldSocket.emit("message", { data: JSON.stringify({ version: "2", event: "hello-fail", trace_id: "trace-connect", emitted_at: Date.now(), payload: { reason: "late" } }) });
|
|
814
|
+
expect(client.state).toBe("reconnecting");
|
|
815
|
+
|
|
816
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
817
|
+
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
818
|
+
} finally {
|
|
819
|
+
vi.useRealTimers();
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("ignores duplicate challenges after the client is connected", async () => {
|
|
824
|
+
const transport = new MockTransport();
|
|
825
|
+
const client = createClawChatClient({
|
|
826
|
+
url: "ws://test",
|
|
827
|
+
token: "token-1",
|
|
828
|
+
deviceId: "agent-1",
|
|
829
|
+
transport,
|
|
830
|
+
traceIdFactory: () => "trace-connect",
|
|
831
|
+
heartbeat: { enabled: false },
|
|
832
|
+
});
|
|
833
|
+
await connectReady(transport, client);
|
|
834
|
+
const sentCount = transport.sent.length;
|
|
835
|
+
|
|
836
|
+
transport.emitInbound(JSON.stringify({
|
|
837
|
+
version: "2",
|
|
838
|
+
event: "connect.challenge",
|
|
839
|
+
trace_id: "challenge-late",
|
|
840
|
+
emitted_at: Date.now(),
|
|
841
|
+
payload: { nonce: "late-nonce" },
|
|
842
|
+
}));
|
|
843
|
+
|
|
844
|
+
expect(client.state).toBe("connected");
|
|
845
|
+
expect(transport.sent).toHaveLength(sentCount);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("rejects connect when sending the challenge response fails", async () => {
|
|
849
|
+
class SendFailTransport extends MockTransport {
|
|
850
|
+
override send(data: string): void {
|
|
851
|
+
const env = JSON.parse(data) as Envelope;
|
|
852
|
+
if (env.event === EVENT.CONNECT) throw new Error("send failed");
|
|
853
|
+
super.send(data);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const transport = new SendFailTransport();
|
|
857
|
+
const client = createClawChatClient({
|
|
858
|
+
url: "ws://test",
|
|
859
|
+
token: "token-1",
|
|
860
|
+
deviceId: "agent-1",
|
|
861
|
+
transport,
|
|
862
|
+
traceIdFactory: () => "trace-connect",
|
|
863
|
+
heartbeat: { enabled: false },
|
|
864
|
+
});
|
|
865
|
+
const connected = client.connect();
|
|
866
|
+
|
|
867
|
+
expect(() => transport.emitInbound(JSON.stringify({
|
|
868
|
+
version: "2",
|
|
869
|
+
event: "connect.challenge",
|
|
870
|
+
trace_id: "challenge-1",
|
|
871
|
+
emitted_at: Date.now(),
|
|
872
|
+
payload: { nonce: "nonce-1" },
|
|
873
|
+
}))).not.toThrow();
|
|
874
|
+
await expect(connected).rejects.toThrow("send failed");
|
|
875
|
+
expect(client.state).toBe("disconnected");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("cleans up terminal hello-fail authentication failures", async () => {
|
|
879
|
+
const transport = new MockTransport();
|
|
880
|
+
const client = createClawChatClient({
|
|
881
|
+
url: "ws://test",
|
|
882
|
+
token: "token-1",
|
|
883
|
+
deviceId: "agent-1",
|
|
884
|
+
transport,
|
|
885
|
+
traceIdFactory: () => "trace-connect",
|
|
886
|
+
heartbeat: { enabled: false },
|
|
887
|
+
});
|
|
888
|
+
const connected = client.connect();
|
|
889
|
+
transport.emitInbound(JSON.stringify({
|
|
890
|
+
version: "2",
|
|
891
|
+
event: "connect.challenge",
|
|
892
|
+
trace_id: "challenge-1",
|
|
893
|
+
emitted_at: Date.now(),
|
|
894
|
+
payload: { nonce: "nonce-1" },
|
|
895
|
+
}));
|
|
896
|
+
|
|
897
|
+
transport.emitInbound(JSON.stringify({
|
|
898
|
+
version: "2",
|
|
899
|
+
event: "hello-fail",
|
|
900
|
+
trace_id: "trace-connect",
|
|
901
|
+
emitted_at: Date.now(),
|
|
902
|
+
payload: { reason: "authentication failed" },
|
|
903
|
+
}));
|
|
904
|
+
|
|
905
|
+
await expect(connected).rejects.toBeInstanceOf(AuthError);
|
|
906
|
+
expect(client.state).toBe("disconnected");
|
|
907
|
+
expect(transport.state).toBe("closed");
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it("rejects malformed hello-fail payloads", async () => {
|
|
911
|
+
const transport = new MockTransport();
|
|
912
|
+
const client = createClawChatClient({
|
|
913
|
+
url: "ws://test",
|
|
914
|
+
token: "token-1",
|
|
915
|
+
deviceId: "agent-1",
|
|
916
|
+
transport,
|
|
917
|
+
traceIdFactory: () => "trace-connect",
|
|
918
|
+
heartbeat: { enabled: false },
|
|
919
|
+
});
|
|
920
|
+
const connected = client.connect();
|
|
921
|
+
transport.emitInbound(JSON.stringify({
|
|
922
|
+
version: "2",
|
|
923
|
+
event: "connect.challenge",
|
|
924
|
+
trace_id: "challenge-1",
|
|
925
|
+
emitted_at: Date.now(),
|
|
926
|
+
payload: { nonce: "nonce-1" },
|
|
927
|
+
}));
|
|
928
|
+
|
|
929
|
+
transport.emitInbound(JSON.stringify({
|
|
930
|
+
version: "2",
|
|
931
|
+
event: "hello-fail",
|
|
932
|
+
trace_id: "trace-connect",
|
|
933
|
+
emitted_at: Date.now(),
|
|
934
|
+
payload: null,
|
|
935
|
+
}));
|
|
936
|
+
|
|
937
|
+
await expect(connected).rejects.toBeInstanceOf(ProtocolError);
|
|
938
|
+
expect(client.state).toBe("disconnected");
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("rejects hello-ok before the connect frame is sent", async () => {
|
|
942
|
+
const transport = new MockTransport();
|
|
943
|
+
const client = createClawChatClient({
|
|
944
|
+
url: "ws://test",
|
|
945
|
+
token: "token-1",
|
|
946
|
+
deviceId: "agent-1",
|
|
947
|
+
transport,
|
|
948
|
+
traceIdFactory: () => "trace-connect",
|
|
949
|
+
heartbeat: { enabled: false },
|
|
950
|
+
});
|
|
951
|
+
const connected = client.connect();
|
|
952
|
+
transport.emitInbound(JSON.stringify({
|
|
953
|
+
version: "2",
|
|
954
|
+
event: "hello-ok",
|
|
955
|
+
trace_id: "trace-connect",
|
|
956
|
+
emitted_at: Date.now(),
|
|
957
|
+
payload: {},
|
|
958
|
+
}));
|
|
959
|
+
|
|
960
|
+
await expect(connected).rejects.toBeInstanceOf(ProtocolError);
|
|
961
|
+
expect(client.state).toBe("disconnected");
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("rejects hello-ok with a mismatched connect trace", async () => {
|
|
965
|
+
const transport = new MockTransport();
|
|
966
|
+
const client = createClawChatClient({
|
|
967
|
+
url: "ws://test",
|
|
968
|
+
token: "token-1",
|
|
969
|
+
deviceId: "agent-1",
|
|
970
|
+
transport,
|
|
971
|
+
traceIdFactory: () => "trace-connect",
|
|
972
|
+
heartbeat: { enabled: false },
|
|
973
|
+
});
|
|
974
|
+
const connected = client.connect();
|
|
975
|
+
transport.emitInbound(JSON.stringify({
|
|
976
|
+
version: "2",
|
|
977
|
+
event: "connect.challenge",
|
|
978
|
+
trace_id: "challenge-1",
|
|
979
|
+
emitted_at: Date.now(),
|
|
980
|
+
payload: { nonce: "nonce-1" },
|
|
981
|
+
}));
|
|
982
|
+
transport.emitInbound(JSON.stringify({
|
|
983
|
+
version: "2",
|
|
984
|
+
event: "hello-ok",
|
|
985
|
+
trace_id: "wrong-trace",
|
|
986
|
+
emitted_at: Date.now(),
|
|
987
|
+
payload: {},
|
|
988
|
+
}));
|
|
989
|
+
|
|
990
|
+
await expect(connected).rejects.toBeInstanceOf(ProtocolError);
|
|
991
|
+
expect(client.state).toBe("disconnected");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("rejects hello-ok with a malformed payload", async () => {
|
|
995
|
+
const transport = new MockTransport();
|
|
996
|
+
const client = createClawChatClient({
|
|
997
|
+
url: "ws://test",
|
|
998
|
+
token: "token-1",
|
|
999
|
+
deviceId: "agent-1",
|
|
1000
|
+
transport,
|
|
1001
|
+
traceIdFactory: () => "trace-connect",
|
|
1002
|
+
heartbeat: { enabled: false },
|
|
1003
|
+
});
|
|
1004
|
+
const connected = client.connect();
|
|
1005
|
+
transport.emitInbound(JSON.stringify({
|
|
1006
|
+
version: "2",
|
|
1007
|
+
event: "connect.challenge",
|
|
1008
|
+
trace_id: "challenge-1",
|
|
1009
|
+
emitted_at: Date.now(),
|
|
1010
|
+
payload: { nonce: "nonce-1" },
|
|
1011
|
+
}));
|
|
1012
|
+
transport.emitInbound(JSON.stringify({
|
|
1013
|
+
version: "2",
|
|
1014
|
+
event: "hello-ok",
|
|
1015
|
+
trace_id: "trace-connect",
|
|
1016
|
+
emitted_at: Date.now(),
|
|
1017
|
+
payload: null,
|
|
1018
|
+
}));
|
|
1019
|
+
|
|
1020
|
+
await expect(connected).rejects.toBeInstanceOf(ProtocolError);
|
|
1021
|
+
expect(client.state).toBe("disconnected");
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("rejects handshake envelopes missing required envelope fields", async () => {
|
|
1025
|
+
const transport = new MockTransport();
|
|
1026
|
+
const client = createClawChatClient({
|
|
1027
|
+
url: "ws://test",
|
|
1028
|
+
token: "token-1",
|
|
1029
|
+
deviceId: "agent-1",
|
|
1030
|
+
transport,
|
|
1031
|
+
traceIdFactory: () => "trace-connect",
|
|
1032
|
+
heartbeat: { enabled: false },
|
|
1033
|
+
});
|
|
1034
|
+
const connected = client.connect();
|
|
1035
|
+
transport.emitInbound(JSON.stringify({
|
|
1036
|
+
version: "2",
|
|
1037
|
+
event: "hello-ok",
|
|
1038
|
+
emitted_at: Date.now(),
|
|
1039
|
+
payload: {},
|
|
1040
|
+
}));
|
|
1041
|
+
|
|
1042
|
+
const outcome = await Promise.race([
|
|
1043
|
+
connected.then(() => "resolved", (err) => err),
|
|
1044
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 0)),
|
|
1045
|
+
]);
|
|
1046
|
+
expect(outcome).toBeInstanceOf(ProtocolError);
|
|
1047
|
+
expect(client.state).toBe("disconnected");
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("rejects malformed challenge frames instead of leaving connect pending", async () => {
|
|
1051
|
+
const transport = new MockTransport();
|
|
1052
|
+
const client = createClawChatClient({
|
|
1053
|
+
url: "ws://test",
|
|
1054
|
+
token: "token-1",
|
|
1055
|
+
deviceId: "agent-1",
|
|
1056
|
+
transport,
|
|
1057
|
+
traceIdFactory: () => "trace-connect",
|
|
1058
|
+
heartbeat: { enabled: false },
|
|
1059
|
+
});
|
|
1060
|
+
const connected = client.connect();
|
|
1061
|
+
transport.emitInbound(JSON.stringify({
|
|
1062
|
+
version: "2",
|
|
1063
|
+
event: "connect.challenge",
|
|
1064
|
+
trace_id: "challenge-1",
|
|
1065
|
+
emitted_at: Date.now(),
|
|
1066
|
+
payload: {},
|
|
1067
|
+
}));
|
|
1068
|
+
|
|
1069
|
+
const outcome = await Promise.race([
|
|
1070
|
+
connected.then(() => "resolved", (err) => err),
|
|
1071
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 0)),
|
|
1072
|
+
]);
|
|
1073
|
+
expect(outcome).toBeInstanceOf(ProtocolError);
|
|
1074
|
+
expect(client.state).toBe("disconnected");
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("rejects null challenge payloads as protocol errors", async () => {
|
|
1078
|
+
const transport = new MockTransport();
|
|
1079
|
+
const client = createClawChatClient({
|
|
1080
|
+
url: "ws://test",
|
|
1081
|
+
token: "token-1",
|
|
1082
|
+
deviceId: "agent-1",
|
|
1083
|
+
transport,
|
|
1084
|
+
traceIdFactory: () => "trace-connect",
|
|
1085
|
+
heartbeat: { enabled: false },
|
|
1086
|
+
});
|
|
1087
|
+
const connected = client.connect();
|
|
1088
|
+
transport.emitInbound(JSON.stringify({
|
|
1089
|
+
version: "2",
|
|
1090
|
+
event: "connect.challenge",
|
|
1091
|
+
trace_id: "challenge-1",
|
|
1092
|
+
emitted_at: Date.now(),
|
|
1093
|
+
payload: null,
|
|
1094
|
+
}));
|
|
1095
|
+
|
|
1096
|
+
await expect(connected).rejects.toBeInstanceOf(ProtocolError);
|
|
1097
|
+
expect(client.state).toBe("disconnected");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("rejects invalid wire frames during handshake", async () => {
|
|
1101
|
+
const transport = new MockTransport();
|
|
1102
|
+
const client = createClawChatClient({
|
|
1103
|
+
url: "ws://test",
|
|
1104
|
+
token: "token-1",
|
|
1105
|
+
deviceId: "agent-1",
|
|
1106
|
+
transport,
|
|
1107
|
+
traceIdFactory: () => "trace-connect",
|
|
1108
|
+
heartbeat: { enabled: false },
|
|
1109
|
+
});
|
|
1110
|
+
const connected = client.connect();
|
|
1111
|
+
transport.emitInbound("not json");
|
|
1112
|
+
|
|
1113
|
+
const outcome = await Promise.race([
|
|
1114
|
+
connected.then(() => "resolved", (err) => err),
|
|
1115
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 0)),
|
|
1116
|
+
]);
|
|
1117
|
+
expect(outcome).toBeInstanceOf(ProtocolError);
|
|
1118
|
+
expect(client.state).toBe("disconnected");
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it("rejects pending ackable sends on unexpected close", async () => {
|
|
1122
|
+
const transport = new MockTransport();
|
|
1123
|
+
const client = createClawChatClient({
|
|
1124
|
+
url: "ws://test",
|
|
1125
|
+
token: "token-1",
|
|
1126
|
+
deviceId: "agent-1",
|
|
1127
|
+
transport,
|
|
1128
|
+
traceIdFactory: vi.fn()
|
|
1129
|
+
.mockReturnValueOnce("trace-connect")
|
|
1130
|
+
.mockReturnValueOnce("trace-send"),
|
|
1131
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
1132
|
+
heartbeat: { enabled: false },
|
|
1133
|
+
});
|
|
1134
|
+
await connectReady(transport, client);
|
|
1135
|
+
|
|
1136
|
+
const send = client.sendAckableEnvelope({
|
|
1137
|
+
eventName: "message.send",
|
|
1138
|
+
chatId: "chat-1",
|
|
1139
|
+
payload: {
|
|
1140
|
+
message_mode: "normal",
|
|
1141
|
+
message: {
|
|
1142
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
1143
|
+
context: { mentions: [], reply: null },
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
});
|
|
1147
|
+
transport.close(1006, "network lost");
|
|
1148
|
+
|
|
1149
|
+
await expect(send).rejects.toBeInstanceOf(TransportError);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it("rejects pending ackable sends on close when reconnect is disabled", async () => {
|
|
1153
|
+
const transport = new MockTransport();
|
|
1154
|
+
const client = createClawChatClient({
|
|
1155
|
+
url: "ws://test",
|
|
1156
|
+
token: "token-1",
|
|
1157
|
+
deviceId: "agent-1",
|
|
1158
|
+
transport,
|
|
1159
|
+
traceIdFactory: vi.fn()
|
|
1160
|
+
.mockReturnValueOnce("trace-connect")
|
|
1161
|
+
.mockReturnValueOnce("trace-send"),
|
|
1162
|
+
reconnect: { enabled: false },
|
|
1163
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
1164
|
+
heartbeat: { enabled: false },
|
|
1165
|
+
});
|
|
1166
|
+
await connectReady(transport, client);
|
|
1167
|
+
|
|
1168
|
+
const send = client.sendAckableEnvelope({
|
|
1169
|
+
eventName: "message.send",
|
|
1170
|
+
chatId: "chat-1",
|
|
1171
|
+
payload: {
|
|
1172
|
+
message_mode: "normal",
|
|
1173
|
+
message: {
|
|
1174
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
1175
|
+
context: { mentions: [], reply: null },
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1179
|
+
transport.close(1006, "network lost");
|
|
1180
|
+
|
|
1181
|
+
const outcome = await Promise.race([
|
|
1182
|
+
send.then(() => "resolved", (err) => err),
|
|
1183
|
+
new Promise((resolve) => setTimeout(() => resolve("pending"), 0)),
|
|
1184
|
+
]);
|
|
1185
|
+
expect(outcome).toBeInstanceOf(TransportError);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it("reconnects after a recoverable close", async () => {
|
|
1189
|
+
vi.useFakeTimers();
|
|
1190
|
+
try {
|
|
1191
|
+
const transport = new MockTransport();
|
|
1192
|
+
const client = createClawChatClient({
|
|
1193
|
+
url: "ws://test",
|
|
1194
|
+
token: "token-1",
|
|
1195
|
+
deviceId: "agent-1",
|
|
1196
|
+
transport,
|
|
1197
|
+
traceIdFactory: () => "trace-1",
|
|
1198
|
+
reconnect: { enabled: true, initialDelay: 50, maxDelay: 50, maxRetries: 1, jitterRatio: 0 },
|
|
1199
|
+
heartbeat: { enabled: false },
|
|
1200
|
+
});
|
|
1201
|
+
const states: string[] = [];
|
|
1202
|
+
client.on("state", ({ to }) => states.push(to));
|
|
1203
|
+
await connectReady(transport, client);
|
|
1204
|
+
|
|
1205
|
+
transport.close(1006, "network lost");
|
|
1206
|
+
expect(client.state).toBe("reconnecting");
|
|
1207
|
+
|
|
1208
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
1209
|
+
expect(states).toContain("connecting");
|
|
1210
|
+
expect(transport.state).toBe("open");
|
|
1211
|
+
await completeHandshake(transport);
|
|
1212
|
+
expect(client.state).toBe("connected");
|
|
1213
|
+
} finally {
|
|
1214
|
+
vi.useRealTimers();
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
});
|