@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/outbound.test.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
2
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
5
|
-
import {
|
|
5
|
+
import type { Envelope, MessageAckPayload } from "./protocol-types.ts";
|
|
6
|
+
import { createClawChatClient, type ClawlingChatClient } from "./ws-client.ts";
|
|
7
|
+
import { MockTransport } from "./mock-transport.ts";
|
|
8
|
+
import {
|
|
9
|
+
flushAlignedOutboundQueue,
|
|
10
|
+
getAlignedOutboundQueueSize,
|
|
11
|
+
sendOpenclawClawlingMedia,
|
|
12
|
+
sendOpenclawClawlingText,
|
|
13
|
+
} from "./outbound.ts";
|
|
6
14
|
|
|
7
15
|
function baseAccount(
|
|
8
16
|
overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
|
|
@@ -16,6 +24,7 @@ function baseAccount(
|
|
|
16
24
|
baseUrl: "",
|
|
17
25
|
token: "tk",
|
|
18
26
|
userId: "agent-1",
|
|
27
|
+
ownerUserId: "owner-1",
|
|
19
28
|
replyMode: "static",
|
|
20
29
|
forwardThinking: true,
|
|
21
30
|
forwardToolCalls: false,
|
|
@@ -33,49 +42,120 @@ function baseAccount(
|
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
type MockOutboundClient = ClawlingChatClient & {
|
|
46
|
+
sent: string[];
|
|
47
|
+
setTransportState: (state: "open" | "closed") => void;
|
|
48
|
+
setState: (state: string | undefined) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function mockClient(options: { transportState?: "open" | "closed"; state?: string } = {}): MockOutboundClient {
|
|
52
|
+
let transportState = options.transportState ?? "open";
|
|
53
|
+
let clientState = options.state;
|
|
54
|
+
let trace = 0;
|
|
55
|
+
const sent: string[] = [];
|
|
56
|
+
const client = Object.assign(new EventEmitter(), {
|
|
57
|
+
sent,
|
|
58
|
+
setTransportState: (state: "open" | "closed") => {
|
|
59
|
+
transportState = state;
|
|
60
|
+
},
|
|
61
|
+
setState: (state: string | undefined) => {
|
|
62
|
+
clientState = state;
|
|
63
|
+
},
|
|
64
|
+
nextTraceId: vi.fn(() => `trace-${++trace}`),
|
|
65
|
+
sendWire: vi.fn((wire: string) => {
|
|
66
|
+
if (transportState !== "open") throw new Error("socket closed");
|
|
67
|
+
sent.push(wire);
|
|
51
68
|
}),
|
|
52
69
|
typing: vi.fn(),
|
|
53
70
|
emitRaw: vi.fn(),
|
|
54
|
-
|
|
71
|
+
sendRawEnvelope: vi.fn(),
|
|
72
|
+
});
|
|
73
|
+
Object.defineProperty(client, "transportState", { get: () => transportState });
|
|
74
|
+
Object.defineProperty(client, "state", { get: () => clientState });
|
|
75
|
+
return client as unknown as MockOutboundClient;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function decodeSent(client: MockOutboundClient): Envelope[] {
|
|
79
|
+
return client.sent.map((wire) => JSON.parse(wire) as Envelope);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emitAck(
|
|
83
|
+
client: MockOutboundClient,
|
|
84
|
+
traceId: string,
|
|
85
|
+
payload: MessageAckPayload = { message_id: "server-m1", accepted_at: 1234 },
|
|
86
|
+
): void {
|
|
87
|
+
client.emit("raw", {
|
|
88
|
+
version: "2",
|
|
89
|
+
event: "message.ack",
|
|
90
|
+
trace_id: traceId,
|
|
91
|
+
emitted_at: Date.now(),
|
|
92
|
+
payload,
|
|
93
|
+
} satisfies Envelope<MessageAckPayload>);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function emitMessageError(client: MockOutboundClient, traceId: string): void {
|
|
97
|
+
client.emit("raw", {
|
|
98
|
+
version: "2",
|
|
99
|
+
event: "message.error",
|
|
100
|
+
trace_id: traceId,
|
|
101
|
+
emitted_at: Date.now(),
|
|
102
|
+
chat_id: "missing-chat",
|
|
103
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
104
|
+
} satisfies Envelope);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function decodeTransportSent(transport: MockTransport): Envelope[] {
|
|
108
|
+
return transport.sent.map((wire) => JSON.parse(wire) as Envelope);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function connectReady(transport: MockTransport, client: ReturnType<typeof createClawChatClient>) {
|
|
112
|
+
const connected = client.connect();
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
transport.emitInbound(JSON.stringify({
|
|
115
|
+
version: "2",
|
|
116
|
+
event: "connect.challenge",
|
|
117
|
+
trace_id: "challenge-1",
|
|
118
|
+
emitted_at: Date.now(),
|
|
119
|
+
payload: { nonce: "nonce-1" },
|
|
120
|
+
}));
|
|
121
|
+
const connectFrame = decodeTransportSent(transport).find((env) => env.event === "connect")!;
|
|
122
|
+
transport.emitInbound(JSON.stringify({
|
|
123
|
+
version: "2",
|
|
124
|
+
event: "hello-ok",
|
|
125
|
+
trace_id: connectFrame.trace_id,
|
|
126
|
+
emitted_at: Date.now(),
|
|
127
|
+
payload: { device_id: "agent-1", delivery_mode: "device_replay" },
|
|
128
|
+
}));
|
|
129
|
+
await connected;
|
|
55
130
|
}
|
|
56
131
|
|
|
57
132
|
describe("openclaw-clawchat outbound", () => {
|
|
58
|
-
it("
|
|
133
|
+
it("sends message.send when no replyCtx", async () => {
|
|
59
134
|
const client = mockClient();
|
|
60
|
-
const
|
|
135
|
+
const send = sendOpenclawClawlingText({
|
|
61
136
|
client,
|
|
62
137
|
account: baseAccount(),
|
|
63
138
|
to: { chatId: "user-1", chatType: "direct" },
|
|
64
139
|
text: "hello",
|
|
65
140
|
});
|
|
66
|
-
|
|
141
|
+
const frame = decodeSent(client)[0]!;
|
|
142
|
+
expect(frame).toMatchObject({
|
|
143
|
+
event: "message.send",
|
|
67
144
|
chat_id: "user-1",
|
|
68
|
-
|
|
145
|
+
payload: {
|
|
146
|
+
message: { body: { fragments: [{ kind: "text", text: "hello" }] } },
|
|
147
|
+
},
|
|
69
148
|
});
|
|
70
|
-
expect(
|
|
71
|
-
|
|
149
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
150
|
+
emitAck(client, frame.trace_id);
|
|
151
|
+
const result = await send;
|
|
72
152
|
expect(result?.messageId).toBe("server-m1");
|
|
73
153
|
expect(result?.acceptedAt).toBe(1234);
|
|
74
154
|
});
|
|
75
155
|
|
|
76
|
-
it("
|
|
156
|
+
it("sends message.reply when replyCtx is provided", async () => {
|
|
77
157
|
const client = mockClient();
|
|
78
|
-
|
|
158
|
+
const send = sendOpenclawClawlingText({
|
|
79
159
|
client,
|
|
80
160
|
account: baseAccount(),
|
|
81
161
|
to: { chatId: "chat-1", chatType: "direct" },
|
|
@@ -88,18 +168,346 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
88
168
|
replyPreviewText: "original",
|
|
89
169
|
},
|
|
90
170
|
});
|
|
91
|
-
|
|
171
|
+
const frame = decodeSent(client)[0]!;
|
|
172
|
+
expect(frame).toMatchObject({
|
|
173
|
+
event: "message.reply",
|
|
92
174
|
chat_id: "chat-1",
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
175
|
+
payload: {
|
|
176
|
+
message: {
|
|
177
|
+
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
178
|
+
context: {
|
|
179
|
+
reply: {
|
|
180
|
+
reply_to_msg_id: "m-orig",
|
|
181
|
+
reply_preview: {
|
|
182
|
+
id: "user-2",
|
|
183
|
+
nick_name: "Sender",
|
|
184
|
+
fragments: [{ kind: "text", text: "original" }],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
98
189
|
},
|
|
99
|
-
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
100
190
|
});
|
|
101
|
-
expect(
|
|
102
|
-
|
|
191
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
192
|
+
emitAck(client, frame.trace_id, { message_id: "server-r1", accepted_at: 5678 });
|
|
193
|
+
await expect(send).resolves.toMatchObject({ messageId: "server-r1", acceptedAt: 5678 });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("sends static outbound with caller supplied payload message_id", async () => {
|
|
197
|
+
const client = mockClient();
|
|
198
|
+
const send = sendOpenclawClawlingText({
|
|
199
|
+
client,
|
|
200
|
+
account: baseAccount(),
|
|
201
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
202
|
+
text: "hello",
|
|
203
|
+
messageId: "local-msg-1",
|
|
204
|
+
});
|
|
205
|
+
const frame = decodeSent(client)[0]!;
|
|
206
|
+
expect((frame.payload as { message_id?: string }).message_id).toBe("local-msg-1");
|
|
207
|
+
emitAck(client, frame.trace_id, { message_id: "local-msg-1", accepted_at: 1234 });
|
|
208
|
+
await expect(send).resolves.toMatchObject({ messageId: "local-msg-1" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("rejects static outbound when ack message_id does not match", async () => {
|
|
212
|
+
const client = mockClient();
|
|
213
|
+
const send = sendOpenclawClawlingText({
|
|
214
|
+
client,
|
|
215
|
+
account: baseAccount(),
|
|
216
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
217
|
+
text: "hello",
|
|
218
|
+
messageId: "local-msg-1",
|
|
219
|
+
});
|
|
220
|
+
const frame = decodeSent(client)[0]!;
|
|
221
|
+
emitAck(client, frame.trace_id, { message_id: "other-msg", accepted_at: 1234 });
|
|
222
|
+
await expect(send).rejects.toThrow("ack message_id mismatch");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("rejects aligned outbound sends from matching message.error", async () => {
|
|
226
|
+
vi.useFakeTimers();
|
|
227
|
+
try {
|
|
228
|
+
const client = mockClient();
|
|
229
|
+
const send = sendOpenclawClawlingText({
|
|
230
|
+
client,
|
|
231
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
232
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
233
|
+
text: "hello",
|
|
234
|
+
});
|
|
235
|
+
const frame = decodeSent(client)[0]!;
|
|
236
|
+
|
|
237
|
+
emitMessageError(client, frame.trace_id);
|
|
238
|
+
|
|
239
|
+
await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
240
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
241
|
+
} finally {
|
|
242
|
+
vi.useRealTimers();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("ignores unmatched message.error without rejecting unrelated aligned sends", async () => {
|
|
247
|
+
vi.useFakeTimers();
|
|
248
|
+
try {
|
|
249
|
+
const client = mockClient();
|
|
250
|
+
const logs: string[] = [];
|
|
251
|
+
const send = sendOpenclawClawlingText({
|
|
252
|
+
client,
|
|
253
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
254
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
255
|
+
text: "hello",
|
|
256
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
257
|
+
});
|
|
258
|
+
const frame = decodeSent(client)[0]!;
|
|
259
|
+
|
|
260
|
+
emitMessageError(client, "trace-other");
|
|
261
|
+
|
|
262
|
+
expect(logs).toContain(
|
|
263
|
+
"clawchat.ws event=ack_unmatched account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-other chat_id=missing-chat",
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
267
|
+
const pendingOutcome = await Promise.race([
|
|
268
|
+
send.then(() => "resolved", (err) => err),
|
|
269
|
+
Promise.resolve("pending"),
|
|
270
|
+
]);
|
|
271
|
+
expect(pendingOutcome).toBe("pending");
|
|
272
|
+
|
|
273
|
+
emitAck(client, frame.trace_id, { message_id: "server-m1", accepted_at: 1234 });
|
|
274
|
+
await expect(send).resolves.toMatchObject({ messageId: "server-m1", acceptedAt: 1234 });
|
|
275
|
+
} finally {
|
|
276
|
+
vi.useRealTimers();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("rejects only the matching aligned send without per-send unmatched logs", async () => {
|
|
281
|
+
const client = mockClient();
|
|
282
|
+
const logs: string[] = [];
|
|
283
|
+
const log = { info: (msg: string) => logs.push(msg), error: (msg: string) => logs.push(msg) };
|
|
284
|
+
const account = baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } });
|
|
285
|
+
|
|
286
|
+
const first = sendOpenclawClawlingText({
|
|
287
|
+
client,
|
|
288
|
+
account,
|
|
289
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
290
|
+
text: "first",
|
|
291
|
+
log,
|
|
292
|
+
});
|
|
293
|
+
const second = sendOpenclawClawlingText({
|
|
294
|
+
client,
|
|
295
|
+
account,
|
|
296
|
+
to: { chatId: "chat-2", chatType: "direct" },
|
|
297
|
+
text: "second",
|
|
298
|
+
log,
|
|
299
|
+
});
|
|
300
|
+
const [firstFrame, secondFrame] = decodeSent(client);
|
|
301
|
+
|
|
302
|
+
emitMessageError(client, firstFrame!.trace_id);
|
|
303
|
+
|
|
304
|
+
await expect(first).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
305
|
+
expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
|
|
306
|
+
|
|
307
|
+
emitAck(client, secondFrame!.trace_id, { message_id: "server-second", accepted_at: 1234 });
|
|
308
|
+
await expect(second).resolves.toMatchObject({ messageId: "server-second" });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("does not let core websocket warn after aligned outbound handles message.error", async () => {
|
|
312
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
313
|
+
try {
|
|
314
|
+
const transport = new MockTransport();
|
|
315
|
+
const client = createClawChatClient({
|
|
316
|
+
url: "ws://test",
|
|
317
|
+
token: "token-1",
|
|
318
|
+
deviceId: "agent-1",
|
|
319
|
+
transport,
|
|
320
|
+
traceIdFactory: vi.fn()
|
|
321
|
+
.mockReturnValueOnce("trace-connect")
|
|
322
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
323
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
324
|
+
heartbeat: { enabled: false },
|
|
325
|
+
});
|
|
326
|
+
await connectReady(transport, client);
|
|
327
|
+
|
|
328
|
+
const send = sendOpenclawClawlingText({
|
|
329
|
+
client,
|
|
330
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
331
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
332
|
+
text: "hello",
|
|
333
|
+
});
|
|
334
|
+
const frame = decodeTransportSent(transport).find((env) => env.event === "message.send")!;
|
|
335
|
+
transport.emitInbound(JSON.stringify({
|
|
336
|
+
version: "2",
|
|
337
|
+
event: "message.error",
|
|
338
|
+
trace_id: frame.trace_id,
|
|
339
|
+
emitted_at: Date.now(),
|
|
340
|
+
chat_id: "missing-chat",
|
|
341
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
345
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
346
|
+
} finally {
|
|
347
|
+
warn.mockRestore();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("logs truly unmatched message.error once when aligned tracker is installed", async () => {
|
|
352
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
353
|
+
try {
|
|
354
|
+
const transport = new MockTransport();
|
|
355
|
+
const client = createClawChatClient({
|
|
356
|
+
url: "ws://test",
|
|
357
|
+
token: "token-1",
|
|
358
|
+
deviceId: "agent-1",
|
|
359
|
+
transport,
|
|
360
|
+
traceIdFactory: vi.fn()
|
|
361
|
+
.mockReturnValueOnce("trace-connect")
|
|
362
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
363
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
364
|
+
heartbeat: { enabled: false },
|
|
365
|
+
});
|
|
366
|
+
await connectReady(transport, client);
|
|
367
|
+
const logs: string[] = [];
|
|
368
|
+
|
|
369
|
+
const aligned = sendOpenclawClawlingText({
|
|
370
|
+
client,
|
|
371
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
372
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
373
|
+
text: "aligned",
|
|
374
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
transport.emitInbound(JSON.stringify({
|
|
378
|
+
version: "2",
|
|
379
|
+
event: "message.error",
|
|
380
|
+
trace_id: "trace-unmatched",
|
|
381
|
+
emitted_at: Date.now(),
|
|
382
|
+
chat_id: "missing-chat",
|
|
383
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
expect(logs.filter((line) => line.includes("event=ack_unmatched"))).toEqual([
|
|
387
|
+
"clawchat.ws event=ack_unmatched account_id=default attempt=1 reconnect_count=0 state=ready action=ignore trace_id=trace-unmatched chat_id=missing-chat",
|
|
388
|
+
]);
|
|
389
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
390
|
+
|
|
391
|
+
transport.emitInbound(JSON.stringify({
|
|
392
|
+
version: "2",
|
|
393
|
+
event: "message.ack",
|
|
394
|
+
trace_id: "trace-aligned",
|
|
395
|
+
emitted_at: Date.now(),
|
|
396
|
+
chat_id: "chat-aligned",
|
|
397
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
398
|
+
}));
|
|
399
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
400
|
+
} finally {
|
|
401
|
+
warn.mockRestore();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("does not swallow truly unmatched message.error when tracker was installed without log sink", async () => {
|
|
406
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
407
|
+
try {
|
|
408
|
+
const transport = new MockTransport();
|
|
409
|
+
const client = createClawChatClient({
|
|
410
|
+
url: "ws://test",
|
|
411
|
+
token: "token-1",
|
|
412
|
+
deviceId: "agent-1",
|
|
413
|
+
transport,
|
|
414
|
+
traceIdFactory: vi.fn()
|
|
415
|
+
.mockReturnValueOnce("trace-connect")
|
|
416
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
417
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
418
|
+
heartbeat: { enabled: false },
|
|
419
|
+
});
|
|
420
|
+
await connectReady(transport, client);
|
|
421
|
+
|
|
422
|
+
const aligned = sendOpenclawClawlingText({
|
|
423
|
+
client,
|
|
424
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
425
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
426
|
+
text: "aligned",
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
transport.emitInbound(JSON.stringify({
|
|
430
|
+
version: "2",
|
|
431
|
+
event: "message.error",
|
|
432
|
+
trace_id: "trace-unmatched",
|
|
433
|
+
emitted_at: Date.now(),
|
|
434
|
+
chat_id: "missing-chat",
|
|
435
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
436
|
+
}));
|
|
437
|
+
|
|
438
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
439
|
+
|
|
440
|
+
transport.emitInbound(JSON.stringify({
|
|
441
|
+
version: "2",
|
|
442
|
+
event: "message.ack",
|
|
443
|
+
trace_id: "trace-aligned",
|
|
444
|
+
emitted_at: Date.now(),
|
|
445
|
+
chat_id: "chat-aligned",
|
|
446
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
447
|
+
}));
|
|
448
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
449
|
+
} finally {
|
|
450
|
+
warn.mockRestore();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("does not log aligned unmatched when message.error matches a core pending send", async () => {
|
|
455
|
+
const transport = new MockTransport();
|
|
456
|
+
const client = createClawChatClient({
|
|
457
|
+
url: "ws://test",
|
|
458
|
+
token: "token-1",
|
|
459
|
+
deviceId: "agent-1",
|
|
460
|
+
transport,
|
|
461
|
+
traceIdFactory: vi.fn()
|
|
462
|
+
.mockReturnValueOnce("trace-connect")
|
|
463
|
+
.mockReturnValueOnce("trace-aligned")
|
|
464
|
+
.mockReturnValueOnce("trace-core"),
|
|
465
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
466
|
+
heartbeat: { enabled: false },
|
|
467
|
+
});
|
|
468
|
+
await connectReady(transport, client);
|
|
469
|
+
const logs: string[] = [];
|
|
470
|
+
|
|
471
|
+
const aligned = sendOpenclawClawlingText({
|
|
472
|
+
client,
|
|
473
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
474
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
475
|
+
text: "aligned",
|
|
476
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
477
|
+
});
|
|
478
|
+
const core = client.sendAckableEnvelope({
|
|
479
|
+
eventName: "message.send",
|
|
480
|
+
chatId: "missing-chat",
|
|
481
|
+
payload: {
|
|
482
|
+
message_mode: "normal",
|
|
483
|
+
message: {
|
|
484
|
+
body: { fragments: [{ kind: "text", text: "core" }] },
|
|
485
|
+
context: { mentions: [], reply: null },
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
transport.emitInbound(JSON.stringify({
|
|
491
|
+
version: "2",
|
|
492
|
+
event: "message.error",
|
|
493
|
+
trace_id: "trace-core",
|
|
494
|
+
emitted_at: Date.now(),
|
|
495
|
+
chat_id: "missing-chat",
|
|
496
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
497
|
+
}));
|
|
498
|
+
|
|
499
|
+
await expect(core).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
500
|
+
expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
|
|
501
|
+
|
|
502
|
+
transport.emitInbound(JSON.stringify({
|
|
503
|
+
version: "2",
|
|
504
|
+
event: "message.ack",
|
|
505
|
+
trace_id: "trace-aligned",
|
|
506
|
+
emitted_at: Date.now(),
|
|
507
|
+
chat_id: "chat-aligned",
|
|
508
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
509
|
+
}));
|
|
510
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
103
511
|
});
|
|
104
512
|
|
|
105
513
|
it("suppresses send when text is empty after trim", async () => {
|
|
@@ -110,60 +518,48 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
110
518
|
to: { chatId: "u", chatType: "direct" },
|
|
111
519
|
text: " ",
|
|
112
520
|
});
|
|
113
|
-
expect(client.
|
|
114
|
-
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
521
|
+
expect(client.sent).toHaveLength(0);
|
|
115
522
|
expect(result).toBeNull();
|
|
116
523
|
});
|
|
117
524
|
|
|
118
|
-
it("propagates SDK errors", async () => {
|
|
119
|
-
const client = {
|
|
120
|
-
...mockClient(),
|
|
121
|
-
sendMessage: vi.fn().mockRejectedValue(new Error("boom")),
|
|
122
|
-
} as unknown as ClawlingChatClient;
|
|
123
|
-
await expect(
|
|
124
|
-
sendOpenclawClawlingText({
|
|
125
|
-
client,
|
|
126
|
-
account: baseAccount(),
|
|
127
|
-
to: { chatId: "u", chatType: "direct" },
|
|
128
|
-
text: "hi",
|
|
129
|
-
}),
|
|
130
|
-
).rejects.toThrow("boom");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
525
|
it("appends mediaFragments after text in body.fragments", async () => {
|
|
134
526
|
const client = mockClient();
|
|
135
|
-
|
|
527
|
+
const send = sendOpenclawClawlingText({
|
|
136
528
|
client,
|
|
137
529
|
account: baseAccount(),
|
|
138
530
|
to: { chatId: "user-1", chatType: "direct" },
|
|
139
531
|
text: "look",
|
|
140
532
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
141
533
|
});
|
|
142
|
-
const
|
|
143
|
-
expect(
|
|
534
|
+
const frame = decodeSent(client)[0]!;
|
|
535
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
144
536
|
{ kind: "text", text: "look" },
|
|
145
537
|
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
146
538
|
]);
|
|
539
|
+
emitAck(client, frame.trace_id);
|
|
540
|
+
await send;
|
|
147
541
|
});
|
|
148
542
|
|
|
149
543
|
it("sends media-only message when text empty but mediaFragments present", async () => {
|
|
150
544
|
const client = mockClient();
|
|
151
|
-
const
|
|
545
|
+
const send = sendOpenclawClawlingText({
|
|
152
546
|
client,
|
|
153
547
|
account: baseAccount(),
|
|
154
548
|
to: { chatId: "user-1", chatType: "direct" },
|
|
155
549
|
text: "",
|
|
156
550
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
157
551
|
});
|
|
158
|
-
const
|
|
159
|
-
expect(
|
|
552
|
+
const frame = decodeSent(client)[0]!;
|
|
553
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
554
|
+
emitAck(client, frame.trace_id);
|
|
555
|
+
const result = await send;
|
|
160
556
|
expect(result?.messageId).toBe("server-m1");
|
|
161
557
|
});
|
|
162
558
|
|
|
163
|
-
it("
|
|
559
|
+
it("preserves replyCtx when media fragments are present", async () => {
|
|
164
560
|
const client = mockClient();
|
|
165
561
|
const log = { info: vi.fn(), error: vi.fn() };
|
|
166
|
-
|
|
562
|
+
const send = sendOpenclawClawlingText({
|
|
167
563
|
client,
|
|
168
564
|
account: baseAccount(),
|
|
169
565
|
to: { chatId: "user-1", chatType: "direct" },
|
|
@@ -177,9 +573,25 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
177
573
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
178
574
|
log,
|
|
179
575
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
576
|
+
const frame = decodeSent(client)[0]!;
|
|
577
|
+
const payload = frame.payload as {
|
|
578
|
+
message: {
|
|
579
|
+
body: { fragments: unknown[] };
|
|
580
|
+
context: { reply: { reply_to_msg_id: string; reply_preview: { id: string; nick_name: string } } };
|
|
581
|
+
};
|
|
582
|
+
};
|
|
583
|
+
expect(frame.event).toBe("message.reply");
|
|
584
|
+
expect(payload.message.context.reply).toMatchObject({
|
|
585
|
+
reply_to_msg_id: "m-orig",
|
|
586
|
+
reply_preview: { id: "user-2", nick_name: "Sender" },
|
|
587
|
+
});
|
|
588
|
+
expect(payload.message.body.fragments).toEqual([
|
|
589
|
+
{ kind: "text", text: "hi" },
|
|
590
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
591
|
+
]);
|
|
592
|
+
emitAck(client, frame.trace_id);
|
|
593
|
+
await send;
|
|
594
|
+
expect(log.info).not.toHaveBeenCalledWith(
|
|
183
595
|
expect.stringMatching(/replyCtx \+ media: downgraded to sendMessage/),
|
|
184
596
|
);
|
|
185
597
|
});
|
|
@@ -193,41 +605,45 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
193
605
|
text: " ",
|
|
194
606
|
mediaFragments: [],
|
|
195
607
|
});
|
|
196
|
-
expect(client.
|
|
608
|
+
expect(client.sent).toHaveLength(0);
|
|
197
609
|
expect(result).toBeNull();
|
|
198
610
|
});
|
|
199
611
|
|
|
200
612
|
it("sendOpenclawClawlingMedia with image and caption sends both fragments", async () => {
|
|
201
613
|
const client = mockClient();
|
|
202
|
-
const
|
|
614
|
+
const send = sendOpenclawClawlingMedia({
|
|
203
615
|
client,
|
|
204
616
|
account: baseAccount(),
|
|
205
617
|
to: { chatId: "user-1", chatType: "direct" },
|
|
206
618
|
text: "look at this",
|
|
207
619
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
208
620
|
});
|
|
209
|
-
const
|
|
210
|
-
expect(
|
|
621
|
+
const frame = decodeSent(client)[0]!;
|
|
622
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
211
623
|
{ kind: "text", text: "look at this" },
|
|
212
624
|
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
213
625
|
]);
|
|
626
|
+
emitAck(client, frame.trace_id);
|
|
627
|
+
const result = await send;
|
|
214
628
|
expect(result?.messageId).toBe("server-m1");
|
|
215
629
|
});
|
|
216
630
|
|
|
217
631
|
it("sendOpenclawClawlingMedia with image only (no text) sends just the media fragment", async () => {
|
|
218
632
|
const client = mockClient();
|
|
219
|
-
const
|
|
633
|
+
const send = sendOpenclawClawlingMedia({
|
|
220
634
|
client,
|
|
221
635
|
account: baseAccount(),
|
|
222
636
|
to: { chatId: "user-1", chatType: "direct" },
|
|
223
637
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
224
638
|
});
|
|
225
|
-
const
|
|
226
|
-
expect(
|
|
639
|
+
const frame = decodeSent(client)[0]!;
|
|
640
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
641
|
+
emitAck(client, frame.trace_id);
|
|
642
|
+
const result = await send;
|
|
227
643
|
expect(result?.messageId).toBe("server-m1");
|
|
228
644
|
});
|
|
229
645
|
|
|
230
|
-
it("sendOpenclawClawlingMedia returns null and does not
|
|
646
|
+
it("sendOpenclawClawlingMedia returns null and does not send when mediaFragments is empty", async () => {
|
|
231
647
|
const client = mockClient();
|
|
232
648
|
const log = { info: vi.fn(), error: vi.fn() };
|
|
233
649
|
const result = await sendOpenclawClawlingMedia({
|
|
@@ -237,11 +653,216 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
237
653
|
mediaFragments: [],
|
|
238
654
|
log,
|
|
239
655
|
});
|
|
240
|
-
expect(client.
|
|
241
|
-
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
656
|
+
expect(client.sent).toHaveLength(0);
|
|
242
657
|
expect(result).toBeNull();
|
|
243
658
|
expect(log.info).toHaveBeenCalledWith(
|
|
244
659
|
expect.stringMatching(/sendMedia called with empty mediaFragments/),
|
|
245
660
|
);
|
|
246
661
|
});
|
|
662
|
+
|
|
663
|
+
it("starts ack timeout only after a queued ackable frame is written", async () => {
|
|
664
|
+
vi.useFakeTimers();
|
|
665
|
+
const logs: string[] = [];
|
|
666
|
+
const client = mockClient({ transportState: "closed" });
|
|
667
|
+
|
|
668
|
+
let rejected: unknown;
|
|
669
|
+
const promise = sendOpenclawClawlingText({
|
|
670
|
+
client,
|
|
671
|
+
account: baseAccount({ ack: { timeout: 15000, autoResendOnTimeout: false } }),
|
|
672
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
673
|
+
text: "hello",
|
|
674
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
675
|
+
});
|
|
676
|
+
promise.catch((err) => {
|
|
677
|
+
rejected = err;
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
await Promise.resolve();
|
|
681
|
+
await vi.advanceTimersByTimeAsync(15001);
|
|
682
|
+
expect(rejected).toBeUndefined();
|
|
683
|
+
expect(client.sent).toHaveLength(0);
|
|
684
|
+
|
|
685
|
+
client.setTransportState("open");
|
|
686
|
+
flushAlignedOutboundQueue(client);
|
|
687
|
+
expect(client.sent).toHaveLength(1);
|
|
688
|
+
|
|
689
|
+
await vi.advanceTimersByTimeAsync(14999);
|
|
690
|
+
expect(rejected).toBeUndefined();
|
|
691
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
692
|
+
|
|
693
|
+
await expect(promise).rejects.toThrow(/ack timeout/);
|
|
694
|
+
expect(logs).toContain(
|
|
695
|
+
"clawchat.ws event=ack_timeout account_id=default attempt=1 reconnect_count=0 state=ready action=reject_no_reconnect event_name=message.send trace_id=trace-1 chat_id=chat-1 timeout_ms=15000",
|
|
696
|
+
);
|
|
697
|
+
vi.useRealTimers();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("rejects an ackable send when queue overflow drops it before write", async () => {
|
|
701
|
+
const logs: string[] = [];
|
|
702
|
+
const client = mockClient({ transportState: "closed" });
|
|
703
|
+
const account = baseAccount();
|
|
704
|
+
|
|
705
|
+
const first = sendOpenclawClawlingText({
|
|
706
|
+
client,
|
|
707
|
+
account,
|
|
708
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
709
|
+
text: "first",
|
|
710
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
for (let i = 0; i < 128; i += 1) {
|
|
714
|
+
sendOpenclawClawlingText({
|
|
715
|
+
client,
|
|
716
|
+
account,
|
|
717
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
718
|
+
text: `queued-${i}`,
|
|
719
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
720
|
+
}).catch(() => {});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
await Promise.resolve();
|
|
724
|
+
|
|
725
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(128);
|
|
726
|
+
expect(client.listenerCount("close")).toBe(1);
|
|
727
|
+
await expect(first).rejects.toThrow(/send queue full/);
|
|
728
|
+
expect(logs.some((line) => line.includes("event=send_queue_drop"))).toBe(true);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("cancels a queued ackable send on terminal close without later flushing it", async () => {
|
|
732
|
+
const client = mockClient({ transportState: "closed" });
|
|
733
|
+
const send = sendOpenclawClawlingText({
|
|
734
|
+
client,
|
|
735
|
+
account: baseAccount(),
|
|
736
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
737
|
+
text: "hello",
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await Promise.resolve();
|
|
741
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
742
|
+
|
|
743
|
+
client.emit("close", { code: 1000, reason: "client close" });
|
|
744
|
+
|
|
745
|
+
await expect(send).rejects.toThrow(/send cancelled because client close/);
|
|
746
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
747
|
+
|
|
748
|
+
client.setTransportState("open");
|
|
749
|
+
flushAlignedOutboundQueue(client);
|
|
750
|
+
expect(client.sent).toHaveLength(0);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("cancels a queued ackable send on disconnected state without close event", async () => {
|
|
754
|
+
const client = mockClient({ transportState: "closed" });
|
|
755
|
+
const send = sendOpenclawClawlingText({
|
|
756
|
+
client,
|
|
757
|
+
account: baseAccount(),
|
|
758
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
759
|
+
text: "hello",
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
await Promise.resolve();
|
|
763
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
764
|
+
|
|
765
|
+
client.emit("state", { from: "connected", to: "disconnected" });
|
|
766
|
+
|
|
767
|
+
await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
|
|
768
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
769
|
+
|
|
770
|
+
client.setTransportState("open");
|
|
771
|
+
flushAlignedOutboundQueue(client);
|
|
772
|
+
expect(client.sent).toHaveLength(0);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("rejects a new ackable send when the client is already disconnected", async () => {
|
|
776
|
+
const client = mockClient({ transportState: "closed", state: "disconnected" });
|
|
777
|
+
const send = sendOpenclawClawlingText({
|
|
778
|
+
client,
|
|
779
|
+
account: baseAccount(),
|
|
780
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
781
|
+
text: "hello",
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
|
|
785
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
786
|
+
expect(client.sent).toHaveLength(0);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("keeps ackable send pending when a ready-state write fails and resolves after retry", async () => {
|
|
790
|
+
const client = mockClient();
|
|
791
|
+
let attempts = 0;
|
|
792
|
+
client.sendWire = vi.fn((wire: string) => {
|
|
793
|
+
attempts += 1;
|
|
794
|
+
if (attempts === 1) throw new Error("socket closed");
|
|
795
|
+
client.sent.push(wire);
|
|
796
|
+
}) as ClawlingChatClient["sendWire"];
|
|
797
|
+
|
|
798
|
+
const send = sendOpenclawClawlingText({
|
|
799
|
+
client,
|
|
800
|
+
account: baseAccount(),
|
|
801
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
802
|
+
text: "hello",
|
|
803
|
+
});
|
|
804
|
+
const observed = send.then(
|
|
805
|
+
(result) => ({ status: "resolved" as const, result }),
|
|
806
|
+
(err: unknown) => ({ status: "rejected" as const, err }),
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
await Promise.resolve();
|
|
810
|
+
|
|
811
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
812
|
+
expect(client.sent).toHaveLength(0);
|
|
813
|
+
|
|
814
|
+
flushAlignedOutboundQueue(client);
|
|
815
|
+
const frame = decodeSent(client)[0]!;
|
|
816
|
+
emitAck(client, frame.trace_id, { message_id: "server-retry", accepted_at: 1234 });
|
|
817
|
+
|
|
818
|
+
await expect(observed).resolves.toMatchObject({
|
|
819
|
+
status: "resolved",
|
|
820
|
+
result: { messageId: "server-retry", acceptedAt: 1234 },
|
|
821
|
+
});
|
|
822
|
+
expect(client.sendWire).toHaveBeenCalledTimes(2);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("requeues a written ackable frame on disconnect before ack", async () => {
|
|
826
|
+
const logs: string[] = [];
|
|
827
|
+
const client = mockClient();
|
|
828
|
+
|
|
829
|
+
const send = sendOpenclawClawlingText({
|
|
830
|
+
client,
|
|
831
|
+
account: baseAccount(),
|
|
832
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
833
|
+
text: "hello",
|
|
834
|
+
messageId: "local-msg-1",
|
|
835
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
await Promise.resolve();
|
|
839
|
+
const firstFrame = decodeSent(client)[0]!;
|
|
840
|
+
expect(firstFrame.event).toBe("message.send");
|
|
841
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
842
|
+
|
|
843
|
+
client.setTransportState("closed");
|
|
844
|
+
client.emit("close", { code: 1006, reason: "network lost" });
|
|
845
|
+
|
|
846
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
847
|
+
|
|
848
|
+
client.setTransportState("open");
|
|
849
|
+
flushAlignedOutboundQueue(client);
|
|
850
|
+
|
|
851
|
+
const frames = decodeSent(client);
|
|
852
|
+
expect(frames).toHaveLength(2);
|
|
853
|
+
expect(frames[1]).toMatchObject({
|
|
854
|
+
event: firstFrame.event,
|
|
855
|
+
trace_id: firstFrame.trace_id,
|
|
856
|
+
chat_id: firstFrame.chat_id,
|
|
857
|
+
payload: firstFrame.payload,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
emitAck(client, firstFrame.trace_id, { message_id: "local-msg-1", accepted_at: 2222 });
|
|
861
|
+
|
|
862
|
+
await expect(send).resolves.toMatchObject({
|
|
863
|
+
messageId: "local-msg-1",
|
|
864
|
+
acceptedAt: 2222,
|
|
865
|
+
});
|
|
866
|
+
expect(logs.some((line) => line.includes("event=send_queued") && line.includes(`trace_id=${firstFrame.trace_id}`))).toBe(true);
|
|
867
|
+
});
|
|
247
868
|
});
|