@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -17
- package/dist/index.js +3 -1
- package/dist/src/api-client.js +71 -12
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +5 -5
- package/dist/src/channel.setup.js +4 -17
- package/dist/src/clawchat-memory.js +290 -0
- package/dist/src/clawchat-metadata.js +235 -0
- package/dist/src/client.js +31 -93
- package/dist/src/commands.js +3 -3
- package/dist/src/config.js +58 -3
- package/dist/src/group-message-coalescer.js +107 -0
- package/dist/src/inbound.js +24 -28
- package/dist/src/login.runtime.js +82 -19
- package/dist/src/media-runtime.js +2 -3
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +281 -56
- package/dist/src/plugin-prompts.js +76 -0
- package/dist/src/profile-prompt.js +150 -0
- package/dist/src/profile-sync.js +169 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -2
- package/dist/src/reply-dispatcher.js +143 -40
- package/dist/src/runtime.js +813 -109
- package/dist/src/storage.js +636 -0
- package/dist/src/tools-schema.js +70 -10
- package/dist/src/tools.js +600 -112
- package/dist/src/ws-alignment.js +8 -0
- package/dist/src/ws-client.js +588 -0
- package/index.ts +6 -1
- package/openclaw.plugin.json +44 -4
- package/package.json +4 -3
- package/prompts/platform.md +7 -0
- package/skills/clawchat/SKILL.md +90 -0
- package/src/api-client.test.ts +360 -15
- package/src/api-client.ts +127 -25
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +71 -4
- package/src/buffered-stream.test.ts +1 -1
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +270 -60
- package/src/channel.setup.ts +9 -18
- package/src/channel.test.ts +33 -25
- package/src/channel.ts +5 -7
- package/src/clawchat-memory.test.ts +372 -0
- package/src/clawchat-memory.ts +363 -0
- package/src/clawchat-metadata.test.ts +350 -0
- package/src/clawchat-metadata.ts +352 -0
- package/src/client.test.ts +57 -48
- package/src/client.ts +37 -129
- package/src/commands.test.ts +2 -2
- package/src/commands.ts +3 -3
- package/src/config.test.ts +169 -4
- package/src/config.ts +86 -6
- package/src/group-message-coalescer.test.ts +223 -0
- package/src/group-message-coalescer.ts +154 -0
- package/src/inbound.test.ts +106 -19
- package/src/inbound.ts +31 -35
- package/src/login.runtime.test.ts +294 -11
- package/src/login.runtime.ts +90 -21
- package/src/manifest.test.ts +86 -14
- package/src/media-runtime.test.ts +31 -2
- package/src/media-runtime.ts +7 -10
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +811 -95
- package/src/outbound.ts +332 -65
- package/src/plugin-entry.test.ts +3 -1
- package/src/plugin-prompts.test.ts +78 -0
- package/src/plugin-prompts.ts +92 -0
- package/src/profile-prompt.test.ts +435 -0
- package/src/profile-prompt.ts +208 -0
- package/src/profile-sync.test.ts +611 -0
- package/src/profile-sync.ts +268 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.ts +2 -2
- package/src/reply-dispatcher.test.ts +720 -135
- package/src/reply-dispatcher.ts +174 -42
- package/src/runtime.test.ts +3884 -337
- package/src/runtime.ts +956 -128
- package/src/storage.test.ts +692 -0
- package/src/storage.ts +989 -0
- package/src/streaming.test.ts +1 -1
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +115 -13
- package/src/tools.test.ts +501 -10
- package/src/tools.ts +739 -133
- package/src/ws-alignment.ts +9 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
package/src/outbound.test.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
1
|
import { EventEmitter } from "node:events";
|
|
3
2
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
4
3
|
import { describe, expect, it, vi } from "vitest";
|
|
5
4
|
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
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";
|
|
6
8
|
import {
|
|
7
9
|
flushAlignedOutboundQueue,
|
|
10
|
+
getAlignedOutboundQueueSize,
|
|
8
11
|
sendOpenclawClawlingMedia,
|
|
9
12
|
sendOpenclawClawlingText,
|
|
10
13
|
} from "./outbound.ts";
|
|
@@ -21,6 +24,7 @@ function baseAccount(
|
|
|
21
24
|
baseUrl: "",
|
|
22
25
|
token: "tk",
|
|
23
26
|
userId: "agent-1",
|
|
27
|
+
ownerUserId: "owner-1",
|
|
24
28
|
replyMode: "static",
|
|
25
29
|
forwardThinking: true,
|
|
26
30
|
forwardToolCalls: false,
|
|
@@ -38,49 +42,283 @@ function baseAccount(
|
|
|
38
42
|
};
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
56
68
|
}),
|
|
57
69
|
typing: vi.fn(),
|
|
58
70
|
emitRaw: vi.fn(),
|
|
59
|
-
|
|
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;
|
|
60
130
|
}
|
|
61
131
|
|
|
62
132
|
describe("openclaw-clawchat outbound", () => {
|
|
63
|
-
it("
|
|
133
|
+
it("sends the legacy silent response marker", async () => {
|
|
134
|
+
const client = mockClient();
|
|
135
|
+
const send = sendOpenclawClawlingText({
|
|
136
|
+
client,
|
|
137
|
+
account: baseAccount(),
|
|
138
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
139
|
+
text: "<clawchat:silent/>",
|
|
140
|
+
});
|
|
141
|
+
const frame = decodeSent(client)[0]!;
|
|
142
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
143
|
+
{ kind: "text", text: "<clawchat:silent/>" },
|
|
144
|
+
]);
|
|
145
|
+
emitAck(client, frame.trace_id, { message_id: "msg-legacy-silent", accepted_at: Date.now() });
|
|
146
|
+
const result = await send;
|
|
147
|
+
|
|
148
|
+
expect(result?.messageId).toBe("msg-legacy-silent");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("suppresses the quoted empty response marker", async () => {
|
|
64
152
|
const client = mockClient();
|
|
65
153
|
const result = await sendOpenclawClawlingText({
|
|
154
|
+
client,
|
|
155
|
+
account: baseAccount(),
|
|
156
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
157
|
+
text: '""',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
expect(decodeSent(client)).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("sends text wrapped in Chinese full-width parentheses", async () => {
|
|
165
|
+
const client = mockClient();
|
|
166
|
+
const send = sendOpenclawClawlingText({
|
|
167
|
+
client,
|
|
168
|
+
account: baseAccount(),
|
|
169
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
170
|
+
text: "(我保持沉默)",
|
|
171
|
+
});
|
|
172
|
+
const frame = decodeSent(client)[0]!;
|
|
173
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
174
|
+
{ kind: "text", text: "(我保持沉默)" },
|
|
175
|
+
]);
|
|
176
|
+
emitAck(client, frame.trace_id, { message_id: "msg-chinese-parentheses", accepted_at: Date.now() });
|
|
177
|
+
const result = await send;
|
|
178
|
+
|
|
179
|
+
expect(result?.messageId).toBe("msg-chinese-parentheses");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("sends text wrapped in ASCII parentheses", async () => {
|
|
183
|
+
const client = mockClient();
|
|
184
|
+
const send = sendOpenclawClawlingText({
|
|
185
|
+
client,
|
|
186
|
+
account: baseAccount(),
|
|
187
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
188
|
+
text: "(staying silent)",
|
|
189
|
+
});
|
|
190
|
+
const frame = decodeSent(client)[0]!;
|
|
191
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
192
|
+
{ kind: "text", text: "(staying silent)" },
|
|
193
|
+
]);
|
|
194
|
+
emitAck(client, frame.trace_id, { message_id: "msg-ascii-parentheses", accepted_at: Date.now() });
|
|
195
|
+
const result = await send;
|
|
196
|
+
|
|
197
|
+
expect(result?.messageId).toBe("msg-ascii-parentheses");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("sends nonempty quoted text", async () => {
|
|
201
|
+
const client = mockClient();
|
|
202
|
+
const send = sendOpenclawClawlingText({
|
|
203
|
+
client,
|
|
204
|
+
account: baseAccount(),
|
|
205
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
206
|
+
text: '"hello"',
|
|
207
|
+
});
|
|
208
|
+
const frame = decodeSent(client)[0]!;
|
|
209
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
210
|
+
{ kind: "text", text: '"hello"' },
|
|
211
|
+
]);
|
|
212
|
+
emitAck(client, frame.trace_id, { message_id: "msg-quoted-text", accepted_at: Date.now() });
|
|
213
|
+
const result = await send;
|
|
214
|
+
|
|
215
|
+
expect(result?.messageId).toBe("msg-quoted-text");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not suppress the silent response marker when media is present", async () => {
|
|
219
|
+
const client = mockClient();
|
|
220
|
+
const send = sendOpenclawClawlingText({
|
|
221
|
+
client,
|
|
222
|
+
account: baseAccount(),
|
|
223
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
224
|
+
text: "<clawchat:silent/>",
|
|
225
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
226
|
+
});
|
|
227
|
+
const frame = decodeSent(client)[0]!;
|
|
228
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
229
|
+
{ kind: "text", text: "<clawchat:silent/>" },
|
|
230
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
231
|
+
]);
|
|
232
|
+
emitAck(client, frame.trace_id, { message_id: "msg-with-media", accepted_at: Date.now() });
|
|
233
|
+
const result = await send;
|
|
234
|
+
|
|
235
|
+
expect(result?.messageId).toBe("msg-with-media");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does not suppress text wrapped in Chinese full-width parentheses when media is present", async () => {
|
|
239
|
+
const client = mockClient();
|
|
240
|
+
const send = sendOpenclawClawlingText({
|
|
241
|
+
client,
|
|
242
|
+
account: baseAccount(),
|
|
243
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
244
|
+
text: "(我保持沉默)",
|
|
245
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
246
|
+
});
|
|
247
|
+
const frame = decodeSent(client)[0]!;
|
|
248
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
249
|
+
{ kind: "text", text: "(我保持沉默)" },
|
|
250
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
251
|
+
]);
|
|
252
|
+
emitAck(client, frame.trace_id, { message_id: "msg-with-chinese-marker-media", accepted_at: Date.now() });
|
|
253
|
+
const result = await send;
|
|
254
|
+
|
|
255
|
+
expect(result?.messageId).toBe("msg-with-chinese-marker-media");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not suppress text wrapped in ASCII parentheses when media is present", async () => {
|
|
259
|
+
const client = mockClient();
|
|
260
|
+
const send = sendOpenclawClawlingText({
|
|
261
|
+
client,
|
|
262
|
+
account: baseAccount(),
|
|
263
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
264
|
+
text: "(staying silent)",
|
|
265
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
266
|
+
});
|
|
267
|
+
const frame = decodeSent(client)[0]!;
|
|
268
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
269
|
+
{ kind: "text", text: "(staying silent)" },
|
|
270
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
271
|
+
]);
|
|
272
|
+
emitAck(client, frame.trace_id, { message_id: "msg-with-ascii-marker-media", accepted_at: Date.now() });
|
|
273
|
+
const result = await send;
|
|
274
|
+
|
|
275
|
+
expect(result?.messageId).toBe("msg-with-ascii-marker-media");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("sends text that only contains the silent response marker", async () => {
|
|
279
|
+
const client = mockClient();
|
|
280
|
+
const send = sendOpenclawClawlingText({
|
|
281
|
+
client,
|
|
282
|
+
account: baseAccount(),
|
|
283
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
284
|
+
text: "note: <clawchat:silent/>",
|
|
285
|
+
});
|
|
286
|
+
const frame = decodeSent(client)[0]!;
|
|
287
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
288
|
+
{ kind: "text", text: "note: <clawchat:silent/>" },
|
|
289
|
+
]);
|
|
290
|
+
emitAck(client, frame.trace_id, { message_id: "msg-normal-text", accepted_at: Date.now() });
|
|
291
|
+
const result = await send;
|
|
292
|
+
|
|
293
|
+
expect(result?.messageId).toBe("msg-normal-text");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("sends message.send when no replyCtx", async () => {
|
|
297
|
+
const client = mockClient();
|
|
298
|
+
const send = sendOpenclawClawlingText({
|
|
66
299
|
client,
|
|
67
300
|
account: baseAccount(),
|
|
68
301
|
to: { chatId: "user-1", chatType: "direct" },
|
|
69
302
|
text: "hello",
|
|
70
303
|
});
|
|
71
|
-
|
|
304
|
+
const frame = decodeSent(client)[0]!;
|
|
305
|
+
expect(frame).toMatchObject({
|
|
306
|
+
event: "message.send",
|
|
72
307
|
chat_id: "user-1",
|
|
73
|
-
|
|
308
|
+
payload: {
|
|
309
|
+
message: { body: { fragments: [{ kind: "text", text: "hello" }] } },
|
|
310
|
+
},
|
|
74
311
|
});
|
|
75
|
-
expect(
|
|
76
|
-
|
|
312
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
313
|
+
emitAck(client, frame.trace_id);
|
|
314
|
+
const result = await send;
|
|
77
315
|
expect(result?.messageId).toBe("server-m1");
|
|
78
316
|
expect(result?.acceptedAt).toBe(1234);
|
|
79
317
|
});
|
|
80
318
|
|
|
81
|
-
it("
|
|
319
|
+
it("sends message.reply when replyCtx is provided", async () => {
|
|
82
320
|
const client = mockClient();
|
|
83
|
-
|
|
321
|
+
const send = sendOpenclawClawlingText({
|
|
84
322
|
client,
|
|
85
323
|
account: baseAccount(),
|
|
86
324
|
to: { chatId: "chat-1", chatType: "direct" },
|
|
@@ -93,18 +331,346 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
93
331
|
replyPreviewText: "original",
|
|
94
332
|
},
|
|
95
333
|
});
|
|
96
|
-
|
|
334
|
+
const frame = decodeSent(client)[0]!;
|
|
335
|
+
expect(frame).toMatchObject({
|
|
336
|
+
event: "message.reply",
|
|
97
337
|
chat_id: "chat-1",
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
338
|
+
payload: {
|
|
339
|
+
message: {
|
|
340
|
+
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
341
|
+
context: {
|
|
342
|
+
reply: {
|
|
343
|
+
reply_to_msg_id: "m-orig",
|
|
344
|
+
reply_preview: {
|
|
345
|
+
id: "user-2",
|
|
346
|
+
nick_name: "Sender",
|
|
347
|
+
fragments: [{ kind: "text", text: "original" }],
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
103
352
|
},
|
|
104
|
-
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
105
353
|
});
|
|
106
|
-
expect(
|
|
107
|
-
|
|
354
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
355
|
+
emitAck(client, frame.trace_id, { message_id: "server-r1", accepted_at: 5678 });
|
|
356
|
+
await expect(send).resolves.toMatchObject({ messageId: "server-r1", acceptedAt: 5678 });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("sends static outbound with caller supplied payload message_id", async () => {
|
|
360
|
+
const client = mockClient();
|
|
361
|
+
const send = sendOpenclawClawlingText({
|
|
362
|
+
client,
|
|
363
|
+
account: baseAccount(),
|
|
364
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
365
|
+
text: "hello",
|
|
366
|
+
messageId: "local-msg-1",
|
|
367
|
+
});
|
|
368
|
+
const frame = decodeSent(client)[0]!;
|
|
369
|
+
expect((frame.payload as { message_id?: string }).message_id).toBe("local-msg-1");
|
|
370
|
+
emitAck(client, frame.trace_id, { message_id: "local-msg-1", accepted_at: 1234 });
|
|
371
|
+
await expect(send).resolves.toMatchObject({ messageId: "local-msg-1" });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("rejects static outbound when ack message_id does not match", async () => {
|
|
375
|
+
const client = mockClient();
|
|
376
|
+
const send = sendOpenclawClawlingText({
|
|
377
|
+
client,
|
|
378
|
+
account: baseAccount(),
|
|
379
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
380
|
+
text: "hello",
|
|
381
|
+
messageId: "local-msg-1",
|
|
382
|
+
});
|
|
383
|
+
const frame = decodeSent(client)[0]!;
|
|
384
|
+
emitAck(client, frame.trace_id, { message_id: "other-msg", accepted_at: 1234 });
|
|
385
|
+
await expect(send).rejects.toThrow("ack message_id mismatch");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("rejects aligned outbound sends from matching message.error", async () => {
|
|
389
|
+
vi.useFakeTimers();
|
|
390
|
+
try {
|
|
391
|
+
const client = mockClient();
|
|
392
|
+
const send = sendOpenclawClawlingText({
|
|
393
|
+
client,
|
|
394
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
395
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
396
|
+
text: "hello",
|
|
397
|
+
});
|
|
398
|
+
const frame = decodeSent(client)[0]!;
|
|
399
|
+
|
|
400
|
+
emitMessageError(client, frame.trace_id);
|
|
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 aligned sends", async () => {
|
|
410
|
+
vi.useFakeTimers();
|
|
411
|
+
try {
|
|
412
|
+
const client = mockClient();
|
|
413
|
+
const logs: string[] = [];
|
|
414
|
+
const send = sendOpenclawClawlingText({
|
|
415
|
+
client,
|
|
416
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
417
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
418
|
+
text: "hello",
|
|
419
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
420
|
+
});
|
|
421
|
+
const frame = decodeSent(client)[0]!;
|
|
422
|
+
|
|
423
|
+
emitMessageError(client, "trace-other");
|
|
424
|
+
|
|
425
|
+
expect(logs).toContain(
|
|
426
|
+
"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",
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
430
|
+
const pendingOutcome = await Promise.race([
|
|
431
|
+
send.then(() => "resolved", (err) => err),
|
|
432
|
+
Promise.resolve("pending"),
|
|
433
|
+
]);
|
|
434
|
+
expect(pendingOutcome).toBe("pending");
|
|
435
|
+
|
|
436
|
+
emitAck(client, frame.trace_id, { message_id: "server-m1", accepted_at: 1234 });
|
|
437
|
+
await expect(send).resolves.toMatchObject({ messageId: "server-m1", acceptedAt: 1234 });
|
|
438
|
+
} finally {
|
|
439
|
+
vi.useRealTimers();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("rejects only the matching aligned send without per-send unmatched logs", async () => {
|
|
444
|
+
const client = mockClient();
|
|
445
|
+
const logs: string[] = [];
|
|
446
|
+
const log = { info: (msg: string) => logs.push(msg), error: (msg: string) => logs.push(msg) };
|
|
447
|
+
const account = baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } });
|
|
448
|
+
|
|
449
|
+
const first = sendOpenclawClawlingText({
|
|
450
|
+
client,
|
|
451
|
+
account,
|
|
452
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
453
|
+
text: "first",
|
|
454
|
+
log,
|
|
455
|
+
});
|
|
456
|
+
const second = sendOpenclawClawlingText({
|
|
457
|
+
client,
|
|
458
|
+
account,
|
|
459
|
+
to: { chatId: "chat-2", chatType: "direct" },
|
|
460
|
+
text: "second",
|
|
461
|
+
log,
|
|
462
|
+
});
|
|
463
|
+
const [firstFrame, secondFrame] = decodeSent(client);
|
|
464
|
+
|
|
465
|
+
emitMessageError(client, firstFrame!.trace_id);
|
|
466
|
+
|
|
467
|
+
await expect(first).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
468
|
+
expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
|
|
469
|
+
|
|
470
|
+
emitAck(client, secondFrame!.trace_id, { message_id: "server-second", accepted_at: 1234 });
|
|
471
|
+
await expect(second).resolves.toMatchObject({ messageId: "server-second" });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("does not let core websocket warn after aligned outbound handles message.error", async () => {
|
|
475
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
476
|
+
try {
|
|
477
|
+
const transport = new MockTransport();
|
|
478
|
+
const client = createClawChatClient({
|
|
479
|
+
url: "ws://test",
|
|
480
|
+
token: "token-1",
|
|
481
|
+
deviceId: "agent-1",
|
|
482
|
+
transport,
|
|
483
|
+
traceIdFactory: vi.fn()
|
|
484
|
+
.mockReturnValueOnce("trace-connect")
|
|
485
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
486
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
487
|
+
heartbeat: { enabled: false },
|
|
488
|
+
});
|
|
489
|
+
await connectReady(transport, client);
|
|
490
|
+
|
|
491
|
+
const send = sendOpenclawClawlingText({
|
|
492
|
+
client,
|
|
493
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
494
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
495
|
+
text: "hello",
|
|
496
|
+
});
|
|
497
|
+
const frame = decodeTransportSent(transport).find((env) => env.event === "message.send")!;
|
|
498
|
+
transport.emitInbound(JSON.stringify({
|
|
499
|
+
version: "2",
|
|
500
|
+
event: "message.error",
|
|
501
|
+
trace_id: frame.trace_id,
|
|
502
|
+
emitted_at: Date.now(),
|
|
503
|
+
chat_id: "missing-chat",
|
|
504
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
505
|
+
}));
|
|
506
|
+
|
|
507
|
+
await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
508
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
509
|
+
} finally {
|
|
510
|
+
warn.mockRestore();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("logs truly unmatched message.error once when aligned tracker is installed", async () => {
|
|
515
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
516
|
+
try {
|
|
517
|
+
const transport = new MockTransport();
|
|
518
|
+
const client = createClawChatClient({
|
|
519
|
+
url: "ws://test",
|
|
520
|
+
token: "token-1",
|
|
521
|
+
deviceId: "agent-1",
|
|
522
|
+
transport,
|
|
523
|
+
traceIdFactory: vi.fn()
|
|
524
|
+
.mockReturnValueOnce("trace-connect")
|
|
525
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
526
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
527
|
+
heartbeat: { enabled: false },
|
|
528
|
+
});
|
|
529
|
+
await connectReady(transport, client);
|
|
530
|
+
const logs: string[] = [];
|
|
531
|
+
|
|
532
|
+
const aligned = sendOpenclawClawlingText({
|
|
533
|
+
client,
|
|
534
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
535
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
536
|
+
text: "aligned",
|
|
537
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
transport.emitInbound(JSON.stringify({
|
|
541
|
+
version: "2",
|
|
542
|
+
event: "message.error",
|
|
543
|
+
trace_id: "trace-unmatched",
|
|
544
|
+
emitted_at: Date.now(),
|
|
545
|
+
chat_id: "missing-chat",
|
|
546
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
expect(logs.filter((line) => line.includes("event=ack_unmatched"))).toEqual([
|
|
550
|
+
"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",
|
|
551
|
+
]);
|
|
552
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
553
|
+
|
|
554
|
+
transport.emitInbound(JSON.stringify({
|
|
555
|
+
version: "2",
|
|
556
|
+
event: "message.ack",
|
|
557
|
+
trace_id: "trace-aligned",
|
|
558
|
+
emitted_at: Date.now(),
|
|
559
|
+
chat_id: "chat-aligned",
|
|
560
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
561
|
+
}));
|
|
562
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
563
|
+
} finally {
|
|
564
|
+
warn.mockRestore();
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("does not swallow truly unmatched message.error when tracker was installed without log sink", async () => {
|
|
569
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
570
|
+
try {
|
|
571
|
+
const transport = new MockTransport();
|
|
572
|
+
const client = createClawChatClient({
|
|
573
|
+
url: "ws://test",
|
|
574
|
+
token: "token-1",
|
|
575
|
+
deviceId: "agent-1",
|
|
576
|
+
transport,
|
|
577
|
+
traceIdFactory: vi.fn()
|
|
578
|
+
.mockReturnValueOnce("trace-connect")
|
|
579
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
580
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
581
|
+
heartbeat: { enabled: false },
|
|
582
|
+
});
|
|
583
|
+
await connectReady(transport, client);
|
|
584
|
+
|
|
585
|
+
const aligned = sendOpenclawClawlingText({
|
|
586
|
+
client,
|
|
587
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
588
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
589
|
+
text: "aligned",
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
transport.emitInbound(JSON.stringify({
|
|
593
|
+
version: "2",
|
|
594
|
+
event: "message.error",
|
|
595
|
+
trace_id: "trace-unmatched",
|
|
596
|
+
emitted_at: Date.now(),
|
|
597
|
+
chat_id: "missing-chat",
|
|
598
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
599
|
+
}));
|
|
600
|
+
|
|
601
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
602
|
+
|
|
603
|
+
transport.emitInbound(JSON.stringify({
|
|
604
|
+
version: "2",
|
|
605
|
+
event: "message.ack",
|
|
606
|
+
trace_id: "trace-aligned",
|
|
607
|
+
emitted_at: Date.now(),
|
|
608
|
+
chat_id: "chat-aligned",
|
|
609
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
610
|
+
}));
|
|
611
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
612
|
+
} finally {
|
|
613
|
+
warn.mockRestore();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("does not log aligned unmatched when message.error matches a core pending send", async () => {
|
|
618
|
+
const transport = new MockTransport();
|
|
619
|
+
const client = createClawChatClient({
|
|
620
|
+
url: "ws://test",
|
|
621
|
+
token: "token-1",
|
|
622
|
+
deviceId: "agent-1",
|
|
623
|
+
transport,
|
|
624
|
+
traceIdFactory: vi.fn()
|
|
625
|
+
.mockReturnValueOnce("trace-connect")
|
|
626
|
+
.mockReturnValueOnce("trace-aligned")
|
|
627
|
+
.mockReturnValueOnce("trace-core"),
|
|
628
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
629
|
+
heartbeat: { enabled: false },
|
|
630
|
+
});
|
|
631
|
+
await connectReady(transport, client);
|
|
632
|
+
const logs: string[] = [];
|
|
633
|
+
|
|
634
|
+
const aligned = sendOpenclawClawlingText({
|
|
635
|
+
client,
|
|
636
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
637
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
638
|
+
text: "aligned",
|
|
639
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
640
|
+
});
|
|
641
|
+
const core = client.sendAckableEnvelope({
|
|
642
|
+
eventName: "message.send",
|
|
643
|
+
chatId: "missing-chat",
|
|
644
|
+
payload: {
|
|
645
|
+
message_mode: "normal",
|
|
646
|
+
message: {
|
|
647
|
+
body: { fragments: [{ kind: "text", text: "core" }] },
|
|
648
|
+
context: { mentions: [], reply: null },
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
transport.emitInbound(JSON.stringify({
|
|
654
|
+
version: "2",
|
|
655
|
+
event: "message.error",
|
|
656
|
+
trace_id: "trace-core",
|
|
657
|
+
emitted_at: Date.now(),
|
|
658
|
+
chat_id: "missing-chat",
|
|
659
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
660
|
+
}));
|
|
661
|
+
|
|
662
|
+
await expect(core).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
663
|
+
expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
|
|
664
|
+
|
|
665
|
+
transport.emitInbound(JSON.stringify({
|
|
666
|
+
version: "2",
|
|
667
|
+
event: "message.ack",
|
|
668
|
+
trace_id: "trace-aligned",
|
|
669
|
+
emitted_at: Date.now(),
|
|
670
|
+
chat_id: "chat-aligned",
|
|
671
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
672
|
+
}));
|
|
673
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
108
674
|
});
|
|
109
675
|
|
|
110
676
|
it("suppresses send when text is empty after trim", async () => {
|
|
@@ -115,60 +681,48 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
115
681
|
to: { chatId: "u", chatType: "direct" },
|
|
116
682
|
text: " ",
|
|
117
683
|
});
|
|
118
|
-
expect(client.
|
|
119
|
-
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
684
|
+
expect(client.sent).toHaveLength(0);
|
|
120
685
|
expect(result).toBeNull();
|
|
121
686
|
});
|
|
122
687
|
|
|
123
|
-
it("propagates SDK errors", async () => {
|
|
124
|
-
const client = {
|
|
125
|
-
...mockClient(),
|
|
126
|
-
sendMessage: vi.fn().mockRejectedValue(new Error("boom")),
|
|
127
|
-
} as unknown as ClawlingChatClient;
|
|
128
|
-
await expect(
|
|
129
|
-
sendOpenclawClawlingText({
|
|
130
|
-
client,
|
|
131
|
-
account: baseAccount(),
|
|
132
|
-
to: { chatId: "u", chatType: "direct" },
|
|
133
|
-
text: "hi",
|
|
134
|
-
}),
|
|
135
|
-
).rejects.toThrow("boom");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
688
|
it("appends mediaFragments after text in body.fragments", async () => {
|
|
139
689
|
const client = mockClient();
|
|
140
|
-
|
|
690
|
+
const send = sendOpenclawClawlingText({
|
|
141
691
|
client,
|
|
142
692
|
account: baseAccount(),
|
|
143
693
|
to: { chatId: "user-1", chatType: "direct" },
|
|
144
694
|
text: "look",
|
|
145
695
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
146
696
|
});
|
|
147
|
-
const
|
|
148
|
-
expect(
|
|
697
|
+
const frame = decodeSent(client)[0]!;
|
|
698
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
149
699
|
{ kind: "text", text: "look" },
|
|
150
700
|
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
151
701
|
]);
|
|
702
|
+
emitAck(client, frame.trace_id);
|
|
703
|
+
await send;
|
|
152
704
|
});
|
|
153
705
|
|
|
154
706
|
it("sends media-only message when text empty but mediaFragments present", async () => {
|
|
155
707
|
const client = mockClient();
|
|
156
|
-
const
|
|
708
|
+
const send = sendOpenclawClawlingText({
|
|
157
709
|
client,
|
|
158
710
|
account: baseAccount(),
|
|
159
711
|
to: { chatId: "user-1", chatType: "direct" },
|
|
160
712
|
text: "",
|
|
161
713
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
162
714
|
});
|
|
163
|
-
const
|
|
164
|
-
expect(
|
|
715
|
+
const frame = decodeSent(client)[0]!;
|
|
716
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
717
|
+
emitAck(client, frame.trace_id);
|
|
718
|
+
const result = await send;
|
|
165
719
|
expect(result?.messageId).toBe("server-m1");
|
|
166
720
|
});
|
|
167
721
|
|
|
168
722
|
it("preserves replyCtx when media fragments are present", async () => {
|
|
169
723
|
const client = mockClient();
|
|
170
724
|
const log = { info: vi.fn(), error: vi.fn() };
|
|
171
|
-
|
|
725
|
+
const send = sendOpenclawClawlingText({
|
|
172
726
|
client,
|
|
173
727
|
account: baseAccount(),
|
|
174
728
|
to: { chatId: "user-1", chatType: "direct" },
|
|
@@ -182,18 +736,24 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
182
736
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
183
737
|
log,
|
|
184
738
|
});
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
739
|
+
const frame = decodeSent(client)[0]!;
|
|
740
|
+
const payload = frame.payload as {
|
|
741
|
+
message: {
|
|
742
|
+
body: { fragments: unknown[] };
|
|
743
|
+
context: { reply: { reply_to_msg_id: string; reply_preview: { id: string; nick_name: string } } };
|
|
744
|
+
};
|
|
745
|
+
};
|
|
746
|
+
expect(frame.event).toBe("message.reply");
|
|
747
|
+
expect(payload.message.context.reply).toMatchObject({
|
|
748
|
+
reply_to_msg_id: "m-orig",
|
|
749
|
+
reply_preview: { id: "user-2", nick_name: "Sender" },
|
|
192
750
|
});
|
|
193
|
-
expect(
|
|
751
|
+
expect(payload.message.body.fragments).toEqual([
|
|
194
752
|
{ kind: "text", text: "hi" },
|
|
195
753
|
{ kind: "image", url: "https://cdn/x.png" },
|
|
196
754
|
]);
|
|
755
|
+
emitAck(client, frame.trace_id);
|
|
756
|
+
await send;
|
|
197
757
|
expect(log.info).not.toHaveBeenCalledWith(
|
|
198
758
|
expect.stringMatching(/replyCtx \+ media: downgraded to sendMessage/),
|
|
199
759
|
);
|
|
@@ -208,41 +768,45 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
208
768
|
text: " ",
|
|
209
769
|
mediaFragments: [],
|
|
210
770
|
});
|
|
211
|
-
expect(client.
|
|
771
|
+
expect(client.sent).toHaveLength(0);
|
|
212
772
|
expect(result).toBeNull();
|
|
213
773
|
});
|
|
214
774
|
|
|
215
775
|
it("sendOpenclawClawlingMedia with image and caption sends both fragments", async () => {
|
|
216
776
|
const client = mockClient();
|
|
217
|
-
const
|
|
777
|
+
const send = sendOpenclawClawlingMedia({
|
|
218
778
|
client,
|
|
219
779
|
account: baseAccount(),
|
|
220
780
|
to: { chatId: "user-1", chatType: "direct" },
|
|
221
781
|
text: "look at this",
|
|
222
782
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
223
783
|
});
|
|
224
|
-
const
|
|
225
|
-
expect(
|
|
784
|
+
const frame = decodeSent(client)[0]!;
|
|
785
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
226
786
|
{ kind: "text", text: "look at this" },
|
|
227
787
|
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
228
788
|
]);
|
|
789
|
+
emitAck(client, frame.trace_id);
|
|
790
|
+
const result = await send;
|
|
229
791
|
expect(result?.messageId).toBe("server-m1");
|
|
230
792
|
});
|
|
231
793
|
|
|
232
794
|
it("sendOpenclawClawlingMedia with image only (no text) sends just the media fragment", async () => {
|
|
233
795
|
const client = mockClient();
|
|
234
|
-
const
|
|
796
|
+
const send = sendOpenclawClawlingMedia({
|
|
235
797
|
client,
|
|
236
798
|
account: baseAccount(),
|
|
237
799
|
to: { chatId: "user-1", chatType: "direct" },
|
|
238
800
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
239
801
|
});
|
|
240
|
-
const
|
|
241
|
-
expect(
|
|
802
|
+
const frame = decodeSent(client)[0]!;
|
|
803
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
804
|
+
emitAck(client, frame.trace_id);
|
|
805
|
+
const result = await send;
|
|
242
806
|
expect(result?.messageId).toBe("server-m1");
|
|
243
807
|
});
|
|
244
808
|
|
|
245
|
-
it("sendOpenclawClawlingMedia returns null and does not
|
|
809
|
+
it("sendOpenclawClawlingMedia returns null and does not send when mediaFragments is empty", async () => {
|
|
246
810
|
const client = mockClient();
|
|
247
811
|
const log = { info: vi.fn(), error: vi.fn() };
|
|
248
812
|
const result = await sendOpenclawClawlingMedia({
|
|
@@ -252,8 +816,7 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
252
816
|
mediaFragments: [],
|
|
253
817
|
log,
|
|
254
818
|
});
|
|
255
|
-
expect(client.
|
|
256
|
-
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
819
|
+
expect(client.sent).toHaveLength(0);
|
|
257
820
|
expect(result).toBeNull();
|
|
258
821
|
expect(log.info).toHaveBeenCalledWith(
|
|
259
822
|
expect.stringMatching(/sendMedia called with empty mediaFragments/),
|
|
@@ -263,23 +826,7 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
263
826
|
it("starts ack timeout only after a queued ackable frame is written", async () => {
|
|
264
827
|
vi.useFakeTimers();
|
|
265
828
|
const logs: string[] = [];
|
|
266
|
-
const
|
|
267
|
-
const transport = {
|
|
268
|
-
state: "closed",
|
|
269
|
-
send: vi.fn((wire: string) => {
|
|
270
|
-
if (transport.state !== "open") throw new Error("socket closed");
|
|
271
|
-
sent.push(wire);
|
|
272
|
-
}),
|
|
273
|
-
};
|
|
274
|
-
let trace = 0;
|
|
275
|
-
const client = Object.assign(new EventEmitter(), {
|
|
276
|
-
opts: {
|
|
277
|
-
transport,
|
|
278
|
-
traceIdFactory: () => `trace-${++trace}`,
|
|
279
|
-
},
|
|
280
|
-
sendMessage: vi.fn(),
|
|
281
|
-
replyMessage: vi.fn(),
|
|
282
|
-
}) as unknown as ClawlingChatClient;
|
|
829
|
+
const client = mockClient({ transportState: "closed" });
|
|
283
830
|
|
|
284
831
|
let rejected: unknown;
|
|
285
832
|
const promise = sendOpenclawClawlingText({
|
|
@@ -296,11 +843,11 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
296
843
|
await Promise.resolve();
|
|
297
844
|
await vi.advanceTimersByTimeAsync(15001);
|
|
298
845
|
expect(rejected).toBeUndefined();
|
|
299
|
-
expect(sent).toHaveLength(0);
|
|
846
|
+
expect(client.sent).toHaveLength(0);
|
|
300
847
|
|
|
301
|
-
|
|
848
|
+
client.setTransportState("open");
|
|
302
849
|
flushAlignedOutboundQueue(client);
|
|
303
|
-
expect(sent).toHaveLength(1);
|
|
850
|
+
expect(client.sent).toHaveLength(1);
|
|
304
851
|
|
|
305
852
|
await vi.advanceTimersByTimeAsync(14999);
|
|
306
853
|
expect(rejected).toBeUndefined();
|
|
@@ -312,4 +859,173 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
312
859
|
);
|
|
313
860
|
vi.useRealTimers();
|
|
314
861
|
});
|
|
862
|
+
|
|
863
|
+
it("rejects an ackable send when queue overflow drops it before write", async () => {
|
|
864
|
+
const logs: string[] = [];
|
|
865
|
+
const client = mockClient({ transportState: "closed" });
|
|
866
|
+
const account = baseAccount();
|
|
867
|
+
|
|
868
|
+
const first = sendOpenclawClawlingText({
|
|
869
|
+
client,
|
|
870
|
+
account,
|
|
871
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
872
|
+
text: "first",
|
|
873
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
for (let i = 0; i < 128; i += 1) {
|
|
877
|
+
sendOpenclawClawlingText({
|
|
878
|
+
client,
|
|
879
|
+
account,
|
|
880
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
881
|
+
text: `queued-${i}`,
|
|
882
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
883
|
+
}).catch(() => {});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
await Promise.resolve();
|
|
887
|
+
|
|
888
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(128);
|
|
889
|
+
expect(client.listenerCount("close")).toBe(1);
|
|
890
|
+
await expect(first).rejects.toThrow(/send queue full/);
|
|
891
|
+
expect(logs.some((line) => line.includes("event=send_queue_drop"))).toBe(true);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("cancels a queued ackable send on terminal close without later flushing it", async () => {
|
|
895
|
+
const client = mockClient({ transportState: "closed" });
|
|
896
|
+
const send = sendOpenclawClawlingText({
|
|
897
|
+
client,
|
|
898
|
+
account: baseAccount(),
|
|
899
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
900
|
+
text: "hello",
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
await Promise.resolve();
|
|
904
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
905
|
+
|
|
906
|
+
client.emit("close", { code: 1000, reason: "client close" });
|
|
907
|
+
|
|
908
|
+
await expect(send).rejects.toThrow(/send cancelled because client close/);
|
|
909
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
910
|
+
|
|
911
|
+
client.setTransportState("open");
|
|
912
|
+
flushAlignedOutboundQueue(client);
|
|
913
|
+
expect(client.sent).toHaveLength(0);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("cancels a queued ackable send on disconnected state without close event", async () => {
|
|
917
|
+
const client = mockClient({ transportState: "closed" });
|
|
918
|
+
const send = sendOpenclawClawlingText({
|
|
919
|
+
client,
|
|
920
|
+
account: baseAccount(),
|
|
921
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
922
|
+
text: "hello",
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
await Promise.resolve();
|
|
926
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
927
|
+
|
|
928
|
+
client.emit("state", { from: "connected", to: "disconnected" });
|
|
929
|
+
|
|
930
|
+
await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
|
|
931
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
932
|
+
|
|
933
|
+
client.setTransportState("open");
|
|
934
|
+
flushAlignedOutboundQueue(client);
|
|
935
|
+
expect(client.sent).toHaveLength(0);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("rejects a new ackable send when the client is already disconnected", async () => {
|
|
939
|
+
const client = mockClient({ transportState: "closed", state: "disconnected" });
|
|
940
|
+
const send = sendOpenclawClawlingText({
|
|
941
|
+
client,
|
|
942
|
+
account: baseAccount(),
|
|
943
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
944
|
+
text: "hello",
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
|
|
948
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
949
|
+
expect(client.sent).toHaveLength(0);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("keeps ackable send pending when a ready-state write fails and resolves after retry", async () => {
|
|
953
|
+
const client = mockClient();
|
|
954
|
+
let attempts = 0;
|
|
955
|
+
client.sendWire = vi.fn((wire: string) => {
|
|
956
|
+
attempts += 1;
|
|
957
|
+
if (attempts === 1) throw new Error("socket closed");
|
|
958
|
+
client.sent.push(wire);
|
|
959
|
+
}) as ClawlingChatClient["sendWire"];
|
|
960
|
+
|
|
961
|
+
const send = sendOpenclawClawlingText({
|
|
962
|
+
client,
|
|
963
|
+
account: baseAccount(),
|
|
964
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
965
|
+
text: "hello",
|
|
966
|
+
});
|
|
967
|
+
const observed = send.then(
|
|
968
|
+
(result) => ({ status: "resolved" as const, result }),
|
|
969
|
+
(err: unknown) => ({ status: "rejected" as const, err }),
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
await Promise.resolve();
|
|
973
|
+
|
|
974
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
975
|
+
expect(client.sent).toHaveLength(0);
|
|
976
|
+
|
|
977
|
+
flushAlignedOutboundQueue(client);
|
|
978
|
+
const frame = decodeSent(client)[0]!;
|
|
979
|
+
emitAck(client, frame.trace_id, { message_id: "server-retry", accepted_at: 1234 });
|
|
980
|
+
|
|
981
|
+
await expect(observed).resolves.toMatchObject({
|
|
982
|
+
status: "resolved",
|
|
983
|
+
result: { messageId: "server-retry", acceptedAt: 1234 },
|
|
984
|
+
});
|
|
985
|
+
expect(client.sendWire).toHaveBeenCalledTimes(2);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it("requeues a written ackable frame on disconnect before ack", async () => {
|
|
989
|
+
const logs: string[] = [];
|
|
990
|
+
const client = mockClient();
|
|
991
|
+
|
|
992
|
+
const send = sendOpenclawClawlingText({
|
|
993
|
+
client,
|
|
994
|
+
account: baseAccount(),
|
|
995
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
996
|
+
text: "hello",
|
|
997
|
+
messageId: "local-msg-1",
|
|
998
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
await Promise.resolve();
|
|
1002
|
+
const firstFrame = decodeSent(client)[0]!;
|
|
1003
|
+
expect(firstFrame.event).toBe("message.send");
|
|
1004
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
1005
|
+
|
|
1006
|
+
client.setTransportState("closed");
|
|
1007
|
+
client.emit("close", { code: 1006, reason: "network lost" });
|
|
1008
|
+
|
|
1009
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
1010
|
+
|
|
1011
|
+
client.setTransportState("open");
|
|
1012
|
+
flushAlignedOutboundQueue(client);
|
|
1013
|
+
|
|
1014
|
+
const frames = decodeSent(client);
|
|
1015
|
+
expect(frames).toHaveLength(2);
|
|
1016
|
+
expect(frames[1]).toMatchObject({
|
|
1017
|
+
event: firstFrame.event,
|
|
1018
|
+
trace_id: firstFrame.trace_id,
|
|
1019
|
+
chat_id: firstFrame.chat_id,
|
|
1020
|
+
payload: firstFrame.payload,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
emitAck(client, firstFrame.trace_id, { message_id: "local-msg-1", accepted_at: 2222 });
|
|
1024
|
+
|
|
1025
|
+
await expect(send).resolves.toMatchObject({
|
|
1026
|
+
messageId: "local-msg-1",
|
|
1027
|
+
acceptedAt: 2222,
|
|
1028
|
+
});
|
|
1029
|
+
expect(logs.some((line) => line.includes("event=send_queued") && line.includes(`trace_id=${firstFrame.trace_id}`))).toBe(true);
|
|
1030
|
+
});
|
|
315
1031
|
});
|