@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,1269 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
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";
|
|
8
|
+
import {
|
|
9
|
+
flushAlignedOutboundQueue,
|
|
10
|
+
getAlignedOutboundQueueSize,
|
|
11
|
+
sendOpenclawClawlingMedia,
|
|
12
|
+
sendOpenclawClawlingMentionMessage,
|
|
13
|
+
sendOpenclawClawlingText,
|
|
14
|
+
} from "./outbound.ts";
|
|
15
|
+
|
|
16
|
+
function baseAccount(
|
|
17
|
+
overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
|
|
18
|
+
): ResolvedOpenclawClawlingAccount {
|
|
19
|
+
return {
|
|
20
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
21
|
+
name: "clawchat-plugin-openclaw",
|
|
22
|
+
enabled: true,
|
|
23
|
+
configured: true,
|
|
24
|
+
websocketUrl: "ws://t",
|
|
25
|
+
baseUrl: "",
|
|
26
|
+
token: "tk",
|
|
27
|
+
agentId: "",
|
|
28
|
+
userId: "agent-1",
|
|
29
|
+
ownerUserId: "owner-1",
|
|
30
|
+
groupMode: "all",
|
|
31
|
+
groupCommandMode: "owner",
|
|
32
|
+
groups: {},
|
|
33
|
+
forwardThinking: true,
|
|
34
|
+
forwardToolCalls: false,
|
|
35
|
+
richInteractions: false,
|
|
36
|
+
allowFrom: [],
|
|
37
|
+
reconnect: {
|
|
38
|
+
initialDelay: 1000,
|
|
39
|
+
maxDelay: 30000,
|
|
40
|
+
jitterRatio: 0.3,
|
|
41
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
42
|
+
},
|
|
43
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
44
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type MockOutboundClient = ClawlingChatClient & {
|
|
50
|
+
sent: string[];
|
|
51
|
+
setTransportState: (state: "open" | "closed") => void;
|
|
52
|
+
setState: (state: string | undefined) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function mockClient(options: { transportState?: "open" | "closed"; state?: string } = {}): MockOutboundClient {
|
|
56
|
+
let transportState = options.transportState ?? "open";
|
|
57
|
+
let clientState = options.state;
|
|
58
|
+
let trace = 0;
|
|
59
|
+
const sent: string[] = [];
|
|
60
|
+
const client = Object.assign(new EventEmitter(), {
|
|
61
|
+
sent,
|
|
62
|
+
setTransportState: (state: "open" | "closed") => {
|
|
63
|
+
transportState = state;
|
|
64
|
+
},
|
|
65
|
+
setState: (state: string | undefined) => {
|
|
66
|
+
clientState = state;
|
|
67
|
+
},
|
|
68
|
+
nextTraceId: vi.fn(() => `trace-${++trace}`),
|
|
69
|
+
sendWire: vi.fn((wire: string) => {
|
|
70
|
+
if (transportState !== "open") throw new Error("socket closed");
|
|
71
|
+
sent.push(wire);
|
|
72
|
+
}),
|
|
73
|
+
typing: vi.fn(),
|
|
74
|
+
emitRaw: vi.fn(),
|
|
75
|
+
sendRawEnvelope: vi.fn(),
|
|
76
|
+
});
|
|
77
|
+
Object.defineProperty(client, "transportState", { get: () => transportState });
|
|
78
|
+
Object.defineProperty(client, "state", { get: () => clientState });
|
|
79
|
+
return client as unknown as MockOutboundClient;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function decodeSent(client: MockOutboundClient): Envelope[] {
|
|
83
|
+
return client.sent.map((wire) => JSON.parse(wire) as Envelope);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function emitAck(
|
|
87
|
+
client: MockOutboundClient,
|
|
88
|
+
traceId: string,
|
|
89
|
+
payload: MessageAckPayload = { message_id: "server-m1", accepted_at: 1234 },
|
|
90
|
+
): void {
|
|
91
|
+
client.emit("raw", {
|
|
92
|
+
version: "2",
|
|
93
|
+
event: "message.ack",
|
|
94
|
+
trace_id: traceId,
|
|
95
|
+
emitted_at: Date.now(),
|
|
96
|
+
payload,
|
|
97
|
+
} satisfies Envelope<MessageAckPayload>);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emitMessageError(client: MockOutboundClient, traceId: string): void {
|
|
101
|
+
client.emit("raw", {
|
|
102
|
+
version: "2",
|
|
103
|
+
event: "message.error",
|
|
104
|
+
trace_id: traceId,
|
|
105
|
+
emitted_at: Date.now(),
|
|
106
|
+
chat_id: "missing-chat",
|
|
107
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
108
|
+
} satisfies Envelope);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function decodeTransportSent(transport: MockTransport): Envelope[] {
|
|
112
|
+
return transport.sent.map((wire) => JSON.parse(wire) as Envelope);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function connectReady(transport: MockTransport, client: ReturnType<typeof createClawChatClient>) {
|
|
116
|
+
const connected = client.connect();
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
transport.emitInbound(JSON.stringify({
|
|
119
|
+
version: "2",
|
|
120
|
+
event: "connect.challenge",
|
|
121
|
+
trace_id: "challenge-1",
|
|
122
|
+
emitted_at: Date.now(),
|
|
123
|
+
payload: { nonce: "nonce-1" },
|
|
124
|
+
}));
|
|
125
|
+
const connectFrame = decodeTransportSent(transport).find((env) => env.event === "connect")!;
|
|
126
|
+
transport.emitInbound(JSON.stringify({
|
|
127
|
+
version: "2",
|
|
128
|
+
event: "hello-ok",
|
|
129
|
+
trace_id: connectFrame.trace_id,
|
|
130
|
+
emitted_at: Date.now(),
|
|
131
|
+
payload: { device_id: "agent-1", delivery_mode: "device_replay" },
|
|
132
|
+
}));
|
|
133
|
+
await connected;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("clawchat-plugin-openclaw outbound", () => {
|
|
137
|
+
it("sends the legacy silent response marker", async () => {
|
|
138
|
+
const client = mockClient();
|
|
139
|
+
const send = sendOpenclawClawlingText({
|
|
140
|
+
client,
|
|
141
|
+
account: baseAccount(),
|
|
142
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
143
|
+
text: "<clawchat:silent/>",
|
|
144
|
+
});
|
|
145
|
+
const frame = decodeSent(client)[0]!;
|
|
146
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
147
|
+
{ kind: "text", text: "<clawchat:silent/>" },
|
|
148
|
+
]);
|
|
149
|
+
emitAck(client, frame.trace_id, { message_id: "msg-legacy-silent", accepted_at: Date.now() });
|
|
150
|
+
const result = await send;
|
|
151
|
+
|
|
152
|
+
expect(result?.messageId).toBe("msg-legacy-silent");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("suppresses the quoted empty response marker", async () => {
|
|
156
|
+
const client = mockClient();
|
|
157
|
+
const result = await sendOpenclawClawlingText({
|
|
158
|
+
client,
|
|
159
|
+
account: baseAccount(),
|
|
160
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
161
|
+
text: '""',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result).toBeNull();
|
|
165
|
+
expect(decodeSent(client)).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("suppresses the no-reply token", async () => {
|
|
169
|
+
const client = mockClient();
|
|
170
|
+
const result = await sendOpenclawClawlingText({
|
|
171
|
+
client,
|
|
172
|
+
account: baseAccount(),
|
|
173
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
174
|
+
text: "<clawchat:no-reply/>",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result).toBeNull();
|
|
178
|
+
expect(decodeSent(client)).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("sends text wrapped in Chinese full-width parentheses", async () => {
|
|
182
|
+
const client = mockClient();
|
|
183
|
+
const send = sendOpenclawClawlingText({
|
|
184
|
+
client,
|
|
185
|
+
account: baseAccount(),
|
|
186
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
187
|
+
text: "(我保持沉默)",
|
|
188
|
+
});
|
|
189
|
+
const frame = decodeSent(client)[0]!;
|
|
190
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
191
|
+
{ kind: "text", text: "(我保持沉默)" },
|
|
192
|
+
]);
|
|
193
|
+
emitAck(client, frame.trace_id, { message_id: "msg-chinese-parentheses", accepted_at: Date.now() });
|
|
194
|
+
const result = await send;
|
|
195
|
+
|
|
196
|
+
expect(result?.messageId).toBe("msg-chinese-parentheses");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("sends text wrapped in ASCII parentheses", async () => {
|
|
200
|
+
const client = mockClient();
|
|
201
|
+
const send = sendOpenclawClawlingText({
|
|
202
|
+
client,
|
|
203
|
+
account: baseAccount(),
|
|
204
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
205
|
+
text: "(staying silent)",
|
|
206
|
+
});
|
|
207
|
+
const frame = decodeSent(client)[0]!;
|
|
208
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
209
|
+
{ kind: "text", text: "(staying silent)" },
|
|
210
|
+
]);
|
|
211
|
+
emitAck(client, frame.trace_id, { message_id: "msg-ascii-parentheses", accepted_at: Date.now() });
|
|
212
|
+
const result = await send;
|
|
213
|
+
|
|
214
|
+
expect(result?.messageId).toBe("msg-ascii-parentheses");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("sends nonempty quoted text", async () => {
|
|
218
|
+
const client = mockClient();
|
|
219
|
+
const send = sendOpenclawClawlingText({
|
|
220
|
+
client,
|
|
221
|
+
account: baseAccount(),
|
|
222
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
223
|
+
text: '"hello"',
|
|
224
|
+
});
|
|
225
|
+
const frame = decodeSent(client)[0]!;
|
|
226
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
227
|
+
{ kind: "text", text: '"hello"' },
|
|
228
|
+
]);
|
|
229
|
+
emitAck(client, frame.trace_id, { message_id: "msg-quoted-text", accepted_at: Date.now() });
|
|
230
|
+
const result = await send;
|
|
231
|
+
|
|
232
|
+
expect(result?.messageId).toBe("msg-quoted-text");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("does not suppress the silent response marker when media is present", async () => {
|
|
236
|
+
const client = mockClient();
|
|
237
|
+
const send = sendOpenclawClawlingText({
|
|
238
|
+
client,
|
|
239
|
+
account: baseAccount(),
|
|
240
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
241
|
+
text: "<clawchat:silent/>",
|
|
242
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
243
|
+
});
|
|
244
|
+
const frame = decodeSent(client)[0]!;
|
|
245
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
246
|
+
{ kind: "text", text: "<clawchat:silent/>" },
|
|
247
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
248
|
+
]);
|
|
249
|
+
emitAck(client, frame.trace_id, { message_id: "msg-with-media", accepted_at: Date.now() });
|
|
250
|
+
const result = await send;
|
|
251
|
+
|
|
252
|
+
expect(result?.messageId).toBe("msg-with-media");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("does not suppress text wrapped in Chinese full-width parentheses when media is present", async () => {
|
|
256
|
+
const client = mockClient();
|
|
257
|
+
const send = sendOpenclawClawlingText({
|
|
258
|
+
client,
|
|
259
|
+
account: baseAccount(),
|
|
260
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
261
|
+
text: "(我保持沉默)",
|
|
262
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
263
|
+
});
|
|
264
|
+
const frame = decodeSent(client)[0]!;
|
|
265
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
266
|
+
{ kind: "text", text: "(我保持沉默)" },
|
|
267
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
268
|
+
]);
|
|
269
|
+
emitAck(client, frame.trace_id, { message_id: "msg-with-chinese-marker-media", accepted_at: Date.now() });
|
|
270
|
+
const result = await send;
|
|
271
|
+
|
|
272
|
+
expect(result?.messageId).toBe("msg-with-chinese-marker-media");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("does not suppress text wrapped in ASCII parentheses when media is present", async () => {
|
|
276
|
+
const client = mockClient();
|
|
277
|
+
const send = sendOpenclawClawlingText({
|
|
278
|
+
client,
|
|
279
|
+
account: baseAccount(),
|
|
280
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
281
|
+
text: "(staying silent)",
|
|
282
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
283
|
+
});
|
|
284
|
+
const frame = decodeSent(client)[0]!;
|
|
285
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
286
|
+
{ kind: "text", text: "(staying silent)" },
|
|
287
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
288
|
+
]);
|
|
289
|
+
emitAck(client, frame.trace_id, { message_id: "msg-with-ascii-marker-media", accepted_at: Date.now() });
|
|
290
|
+
const result = await send;
|
|
291
|
+
|
|
292
|
+
expect(result?.messageId).toBe("msg-with-ascii-marker-media");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("sends text that only contains the silent response marker", async () => {
|
|
296
|
+
const client = mockClient();
|
|
297
|
+
const send = sendOpenclawClawlingText({
|
|
298
|
+
client,
|
|
299
|
+
account: baseAccount(),
|
|
300
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
301
|
+
text: "note: <clawchat:silent/>",
|
|
302
|
+
});
|
|
303
|
+
const frame = decodeSent(client)[0]!;
|
|
304
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
305
|
+
{ kind: "text", text: "note: <clawchat:silent/>" },
|
|
306
|
+
]);
|
|
307
|
+
emitAck(client, frame.trace_id, { message_id: "msg-normal-text", accepted_at: Date.now() });
|
|
308
|
+
const result = await send;
|
|
309
|
+
|
|
310
|
+
expect(result?.messageId).toBe("msg-normal-text");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("sends text that mentions the no-reply token", async () => {
|
|
314
|
+
const client = mockClient();
|
|
315
|
+
const send = sendOpenclawClawlingText({
|
|
316
|
+
client,
|
|
317
|
+
account: baseAccount(),
|
|
318
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
319
|
+
text: "note: <clawchat:no-reply/>",
|
|
320
|
+
});
|
|
321
|
+
const frame = decodeSent(client)[0]!;
|
|
322
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
323
|
+
{ kind: "text", text: "note: <clawchat:no-reply/>" },
|
|
324
|
+
]);
|
|
325
|
+
emitAck(client, frame.trace_id, { message_id: "msg-normal-no-reply-text", accepted_at: Date.now() });
|
|
326
|
+
const result = await send;
|
|
327
|
+
|
|
328
|
+
expect(result?.messageId).toBe("msg-normal-no-reply-text");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("sends message.send when no replyCtx", async () => {
|
|
332
|
+
const client = mockClient();
|
|
333
|
+
const send = sendOpenclawClawlingText({
|
|
334
|
+
client,
|
|
335
|
+
account: baseAccount(),
|
|
336
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
337
|
+
text: "hello",
|
|
338
|
+
});
|
|
339
|
+
const frame = decodeSent(client)[0]!;
|
|
340
|
+
expect(frame).toMatchObject({
|
|
341
|
+
event: "message.send",
|
|
342
|
+
chat_id: "user-1",
|
|
343
|
+
payload: {
|
|
344
|
+
message: { body: { fragments: [{ kind: "text", text: "hello" }] } },
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
348
|
+
emitAck(client, frame.trace_id);
|
|
349
|
+
const result = await send;
|
|
350
|
+
expect(result?.messageId).toBe("server-m1");
|
|
351
|
+
expect(result?.acceptedAt).toBe(1234);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("sendOpenclawClawlingMentionMessage emits mention fragments and context mentions", async () => {
|
|
355
|
+
const client = mockClient();
|
|
356
|
+
const send = sendOpenclawClawlingMentionMessage({
|
|
357
|
+
client,
|
|
358
|
+
account: baseAccount(),
|
|
359
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
360
|
+
text: "请看这个",
|
|
361
|
+
mentions: [{ userId: "user-a", display: "Alice" }],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const frame = decodeSent(client).find((env) => env.event === "message.send")!;
|
|
365
|
+
emitAck(client, frame.trace_id, { message_id: "msg-mention-1", accepted_at: 1234 });
|
|
366
|
+
|
|
367
|
+
await expect(send).resolves.toEqual({
|
|
368
|
+
messageId: "msg-mention-1",
|
|
369
|
+
acceptedAt: 1234,
|
|
370
|
+
mentions: ["user-a"],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(frame.payload).toMatchObject({
|
|
374
|
+
message: {
|
|
375
|
+
body: {
|
|
376
|
+
fragments: [
|
|
377
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
378
|
+
{ kind: "text", text: " 请看这个" },
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
context: {
|
|
382
|
+
mentions: [{ kind: "mention", user_id: "user-a", display: "Alice" }],
|
|
383
|
+
reply: null,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("sendOpenclawClawlingMentionMessage emits multiple mention fragments before text", async () => {
|
|
390
|
+
const client = mockClient();
|
|
391
|
+
const send = sendOpenclawClawlingMentionMessage({
|
|
392
|
+
client,
|
|
393
|
+
account: baseAccount(),
|
|
394
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
395
|
+
text: "一起看",
|
|
396
|
+
mentions: [
|
|
397
|
+
{ userId: "user-a", display: "Alice" },
|
|
398
|
+
{ userId: "user-b", display: "Bob" },
|
|
399
|
+
],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const frame = decodeSent(client).find((env) => env.event === "message.send")!;
|
|
403
|
+
emitAck(client, frame.trace_id, { message_id: "msg-mention-2", accepted_at: 1234 });
|
|
404
|
+
|
|
405
|
+
await expect(send).resolves.toMatchObject({
|
|
406
|
+
messageId: "msg-mention-2",
|
|
407
|
+
mentions: ["user-a", "user-b"],
|
|
408
|
+
});
|
|
409
|
+
expect(frame.payload).toMatchObject({
|
|
410
|
+
message: {
|
|
411
|
+
body: {
|
|
412
|
+
fragments: [
|
|
413
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
414
|
+
{ kind: "mention", user_id: "user-b", display: "Bob" },
|
|
415
|
+
{ kind: "text", text: " 一起看" },
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
context: {
|
|
419
|
+
mentions: [
|
|
420
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
421
|
+
{ kind: "mention", user_id: "user-b", display: "Bob" },
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("sendOpenclawClawlingMentionMessage sends mention-only messages without text", async () => {
|
|
429
|
+
const client = mockClient();
|
|
430
|
+
const send = sendOpenclawClawlingMentionMessage({
|
|
431
|
+
client,
|
|
432
|
+
account: baseAccount(),
|
|
433
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
434
|
+
mentions: [{ userId: "user-a", display: "Alice" }],
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const frame = decodeSent(client).find((env) => env.event === "message.send")!;
|
|
438
|
+
emitAck(client, frame.trace_id, { message_id: "msg-mention-only-1", accepted_at: 1234 });
|
|
439
|
+
|
|
440
|
+
expect(frame.payload).toMatchObject({
|
|
441
|
+
message: {
|
|
442
|
+
body: {
|
|
443
|
+
fragments: [
|
|
444
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
445
|
+
],
|
|
446
|
+
},
|
|
447
|
+
context: {
|
|
448
|
+
mentions: [{ kind: "mention", user_id: "user-a", display: "Alice" }],
|
|
449
|
+
reply: null,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
await expect(send).resolves.toEqual({
|
|
454
|
+
messageId: "msg-mention-only-1",
|
|
455
|
+
acceptedAt: 1234,
|
|
456
|
+
mentions: ["user-a"],
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("sendOpenclawClawlingMentionMessage lifts text-only @label into mention display", async () => {
|
|
461
|
+
const client = mockClient();
|
|
462
|
+
const send = sendOpenclawClawlingMentionMessage({
|
|
463
|
+
client,
|
|
464
|
+
account: baseAccount(),
|
|
465
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
466
|
+
text: "@Alice",
|
|
467
|
+
mentions: [{ userId: "user-a" }],
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const frame = decodeSent(client).find((env) => env.event === "message.send")!;
|
|
471
|
+
emitAck(client, frame.trace_id, { message_id: "msg-mention-label-1", accepted_at: 1234 });
|
|
472
|
+
|
|
473
|
+
expect(frame.payload).toMatchObject({
|
|
474
|
+
message: {
|
|
475
|
+
body: {
|
|
476
|
+
fragments: [
|
|
477
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
478
|
+
],
|
|
479
|
+
},
|
|
480
|
+
context: {
|
|
481
|
+
mentions: [{ kind: "mention", user_id: "user-a", display: "Alice" }],
|
|
482
|
+
reply: null,
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
await expect(send).resolves.toMatchObject({
|
|
487
|
+
messageId: "msg-mention-label-1",
|
|
488
|
+
mentions: ["user-a"],
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("sendOpenclawClawlingMentionMessage keeps spaces in a text-only mention display label", async () => {
|
|
493
|
+
const client = mockClient();
|
|
494
|
+
const send = sendOpenclawClawlingMentionMessage({
|
|
495
|
+
client,
|
|
496
|
+
account: baseAccount(),
|
|
497
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
498
|
+
text: "@Super Zero",
|
|
499
|
+
mentions: [{ userId: "user-a" }],
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const frame = decodeSent(client).find((env) => env.event === "message.send")!;
|
|
503
|
+
emitAck(client, frame.trace_id, { message_id: "msg-mention-label-2", accepted_at: 1234 });
|
|
504
|
+
|
|
505
|
+
expect(frame.payload).toMatchObject({
|
|
506
|
+
message: {
|
|
507
|
+
body: {
|
|
508
|
+
fragments: [
|
|
509
|
+
{ kind: "mention", user_id: "user-a", display: "Super Zero" },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
context: {
|
|
513
|
+
mentions: [{ kind: "mention", user_id: "user-a", display: "Super Zero" }],
|
|
514
|
+
reply: null,
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
await expect(send).resolves.toMatchObject({
|
|
519
|
+
messageId: "msg-mention-label-2",
|
|
520
|
+
mentions: ["user-a"],
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("sendOpenclawClawlingMentionMessage preserves reply id without inventing preview", async () => {
|
|
525
|
+
const client = mockClient();
|
|
526
|
+
const send = sendOpenclawClawlingMentionMessage({
|
|
527
|
+
client,
|
|
528
|
+
account: baseAccount(),
|
|
529
|
+
to: { chatId: "group-1", chatType: "group" },
|
|
530
|
+
mentions: [{ userId: "user-a", display: "Alice" }],
|
|
531
|
+
replyCtx: { replyToMessageId: "m-orig" },
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const frame = decodeSent(client)[0]!;
|
|
535
|
+
expect(frame).toMatchObject({
|
|
536
|
+
event: "message.send",
|
|
537
|
+
chat_id: "group-1",
|
|
538
|
+
payload: {
|
|
539
|
+
message: {
|
|
540
|
+
context: {
|
|
541
|
+
mentions: [{ kind: "mention", user_id: "user-a", display: "Alice" }],
|
|
542
|
+
reply: {
|
|
543
|
+
reply_to_msg_id: "m-orig",
|
|
544
|
+
reply_preview: null,
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
emitAck(client, frame.trace_id, { message_id: "msg-mention-reply", accepted_at: 1234 });
|
|
551
|
+
await expect(send).resolves.toMatchObject({
|
|
552
|
+
messageId: "msg-mention-reply",
|
|
553
|
+
mentions: ["user-a"],
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("sends message.reply when replyCtx is provided", async () => {
|
|
558
|
+
const client = mockClient();
|
|
559
|
+
const send = sendOpenclawClawlingText({
|
|
560
|
+
client,
|
|
561
|
+
account: baseAccount(),
|
|
562
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
563
|
+
text: "reply",
|
|
564
|
+
replyCtx: {
|
|
565
|
+
replyToMessageId: "m-orig",
|
|
566
|
+
replyPreviewChatId: "chat-1",
|
|
567
|
+
replyPreviewSenderId: "user-2",
|
|
568
|
+
replyPreviewNickName: "Sender",
|
|
569
|
+
replyPreviewText: "original",
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
const frame = decodeSent(client)[0]!;
|
|
573
|
+
expect(frame).toMatchObject({
|
|
574
|
+
event: "message.reply",
|
|
575
|
+
chat_id: "chat-1",
|
|
576
|
+
payload: {
|
|
577
|
+
message: {
|
|
578
|
+
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
579
|
+
context: {
|
|
580
|
+
reply: {
|
|
581
|
+
reply_to_msg_id: "m-orig",
|
|
582
|
+
reply_preview: {
|
|
583
|
+
id: "user-2",
|
|
584
|
+
nick_name: "Sender",
|
|
585
|
+
fragments: [{ kind: "text", text: "original" }],
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
593
|
+
emitAck(client, frame.trace_id, { message_id: "server-r1", accepted_at: 5678 });
|
|
594
|
+
await expect(send).resolves.toMatchObject({ messageId: "server-r1", acceptedAt: 5678 });
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("sends static outbound with caller supplied payload message_id", async () => {
|
|
598
|
+
const client = mockClient();
|
|
599
|
+
const send = sendOpenclawClawlingText({
|
|
600
|
+
client,
|
|
601
|
+
account: baseAccount(),
|
|
602
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
603
|
+
text: "hello",
|
|
604
|
+
messageId: "local-msg-1",
|
|
605
|
+
});
|
|
606
|
+
const frame = decodeSent(client)[0]!;
|
|
607
|
+
expect((frame.payload as { message_id?: string }).message_id).toBe("local-msg-1");
|
|
608
|
+
emitAck(client, frame.trace_id, { message_id: "local-msg-1", accepted_at: 1234 });
|
|
609
|
+
await expect(send).resolves.toMatchObject({ messageId: "local-msg-1" });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("rejects static outbound when ack message_id does not match", async () => {
|
|
613
|
+
const client = mockClient();
|
|
614
|
+
const send = sendOpenclawClawlingText({
|
|
615
|
+
client,
|
|
616
|
+
account: baseAccount(),
|
|
617
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
618
|
+
text: "hello",
|
|
619
|
+
messageId: "local-msg-1",
|
|
620
|
+
});
|
|
621
|
+
const frame = decodeSent(client)[0]!;
|
|
622
|
+
emitAck(client, frame.trace_id, { message_id: "other-msg", accepted_at: 1234 });
|
|
623
|
+
await expect(send).rejects.toThrow("ack message_id mismatch");
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("rejects aligned outbound sends from matching message.error", async () => {
|
|
627
|
+
vi.useFakeTimers();
|
|
628
|
+
try {
|
|
629
|
+
const client = mockClient();
|
|
630
|
+
const send = sendOpenclawClawlingText({
|
|
631
|
+
client,
|
|
632
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
633
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
634
|
+
text: "hello",
|
|
635
|
+
});
|
|
636
|
+
const frame = decodeSent(client)[0]!;
|
|
637
|
+
|
|
638
|
+
emitMessageError(client, frame.trace_id);
|
|
639
|
+
|
|
640
|
+
await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
641
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
642
|
+
} finally {
|
|
643
|
+
vi.useRealTimers();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("ignores unmatched message.error without rejecting unrelated aligned sends", async () => {
|
|
648
|
+
vi.useFakeTimers();
|
|
649
|
+
try {
|
|
650
|
+
const client = mockClient();
|
|
651
|
+
const logs: string[] = [];
|
|
652
|
+
const send = sendOpenclawClawlingText({
|
|
653
|
+
client,
|
|
654
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
655
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
656
|
+
text: "hello",
|
|
657
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
658
|
+
});
|
|
659
|
+
const frame = decodeSent(client)[0]!;
|
|
660
|
+
|
|
661
|
+
emitMessageError(client, "trace-other");
|
|
662
|
+
|
|
663
|
+
expect(logs).toContain(
|
|
664
|
+
"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",
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
668
|
+
const pendingOutcome = await Promise.race([
|
|
669
|
+
send.then(() => "resolved", (err) => err),
|
|
670
|
+
Promise.resolve("pending"),
|
|
671
|
+
]);
|
|
672
|
+
expect(pendingOutcome).toBe("pending");
|
|
673
|
+
|
|
674
|
+
emitAck(client, frame.trace_id, { message_id: "server-m1", accepted_at: 1234 });
|
|
675
|
+
await expect(send).resolves.toMatchObject({ messageId: "server-m1", acceptedAt: 1234 });
|
|
676
|
+
} finally {
|
|
677
|
+
vi.useRealTimers();
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("rejects only the matching aligned send without per-send unmatched logs", async () => {
|
|
682
|
+
const client = mockClient();
|
|
683
|
+
const logs: string[] = [];
|
|
684
|
+
const log = { info: (msg: string) => logs.push(msg), error: (msg: string) => logs.push(msg) };
|
|
685
|
+
const account = baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } });
|
|
686
|
+
|
|
687
|
+
const first = sendOpenclawClawlingText({
|
|
688
|
+
client,
|
|
689
|
+
account,
|
|
690
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
691
|
+
text: "first",
|
|
692
|
+
log,
|
|
693
|
+
});
|
|
694
|
+
const second = sendOpenclawClawlingText({
|
|
695
|
+
client,
|
|
696
|
+
account,
|
|
697
|
+
to: { chatId: "chat-2", chatType: "direct" },
|
|
698
|
+
text: "second",
|
|
699
|
+
log,
|
|
700
|
+
});
|
|
701
|
+
const [firstFrame, secondFrame] = decodeSent(client);
|
|
702
|
+
|
|
703
|
+
emitMessageError(client, firstFrame!.trace_id);
|
|
704
|
+
|
|
705
|
+
await expect(first).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
706
|
+
expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
|
|
707
|
+
|
|
708
|
+
emitAck(client, secondFrame!.trace_id, { message_id: "server-second", accepted_at: 1234 });
|
|
709
|
+
await expect(second).resolves.toMatchObject({ messageId: "server-second" });
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("does not let core websocket warn after aligned outbound handles message.error", async () => {
|
|
713
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
714
|
+
try {
|
|
715
|
+
const transport = new MockTransport();
|
|
716
|
+
const client = createClawChatClient({
|
|
717
|
+
url: "ws://test",
|
|
718
|
+
token: "token-1",
|
|
719
|
+
deviceId: "agent-1",
|
|
720
|
+
transport,
|
|
721
|
+
traceIdFactory: vi.fn()
|
|
722
|
+
.mockReturnValueOnce("trace-connect")
|
|
723
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
724
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
725
|
+
heartbeat: { enabled: false },
|
|
726
|
+
});
|
|
727
|
+
await connectReady(transport, client);
|
|
728
|
+
|
|
729
|
+
const send = sendOpenclawClawlingText({
|
|
730
|
+
client,
|
|
731
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
732
|
+
to: { chatId: "missing-chat", chatType: "direct" },
|
|
733
|
+
text: "hello",
|
|
734
|
+
});
|
|
735
|
+
const frame = decodeTransportSent(transport).find((env) => env.event === "message.send")!;
|
|
736
|
+
transport.emitInbound(JSON.stringify({
|
|
737
|
+
version: "2",
|
|
738
|
+
event: "message.error",
|
|
739
|
+
trace_id: frame.trace_id,
|
|
740
|
+
emitted_at: Date.now(),
|
|
741
|
+
chat_id: "missing-chat",
|
|
742
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
743
|
+
}));
|
|
744
|
+
|
|
745
|
+
await expect(send).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
746
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
747
|
+
} finally {
|
|
748
|
+
warn.mockRestore();
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("logs truly unmatched message.error once when aligned tracker is installed", async () => {
|
|
753
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
754
|
+
try {
|
|
755
|
+
const transport = new MockTransport();
|
|
756
|
+
const client = createClawChatClient({
|
|
757
|
+
url: "ws://test",
|
|
758
|
+
token: "token-1",
|
|
759
|
+
deviceId: "agent-1",
|
|
760
|
+
transport,
|
|
761
|
+
traceIdFactory: vi.fn()
|
|
762
|
+
.mockReturnValueOnce("trace-connect")
|
|
763
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
764
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
765
|
+
heartbeat: { enabled: false },
|
|
766
|
+
});
|
|
767
|
+
await connectReady(transport, client);
|
|
768
|
+
const logs: string[] = [];
|
|
769
|
+
|
|
770
|
+
const aligned = sendOpenclawClawlingText({
|
|
771
|
+
client,
|
|
772
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
773
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
774
|
+
text: "aligned",
|
|
775
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
transport.emitInbound(JSON.stringify({
|
|
779
|
+
version: "2",
|
|
780
|
+
event: "message.error",
|
|
781
|
+
trace_id: "trace-unmatched",
|
|
782
|
+
emitted_at: Date.now(),
|
|
783
|
+
chat_id: "missing-chat",
|
|
784
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
785
|
+
}));
|
|
786
|
+
|
|
787
|
+
expect(logs.filter((line) => line.includes("event=ack_unmatched"))).toEqual([
|
|
788
|
+
"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",
|
|
789
|
+
]);
|
|
790
|
+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
791
|
+
|
|
792
|
+
transport.emitInbound(JSON.stringify({
|
|
793
|
+
version: "2",
|
|
794
|
+
event: "message.ack",
|
|
795
|
+
trace_id: "trace-aligned",
|
|
796
|
+
emitted_at: Date.now(),
|
|
797
|
+
chat_id: "chat-aligned",
|
|
798
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
799
|
+
}));
|
|
800
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
801
|
+
} finally {
|
|
802
|
+
warn.mockRestore();
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("does not swallow truly unmatched message.error when tracker was installed without log sink", async () => {
|
|
807
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
808
|
+
try {
|
|
809
|
+
const transport = new MockTransport();
|
|
810
|
+
const client = createClawChatClient({
|
|
811
|
+
url: "ws://test",
|
|
812
|
+
token: "token-1",
|
|
813
|
+
deviceId: "agent-1",
|
|
814
|
+
transport,
|
|
815
|
+
traceIdFactory: vi.fn()
|
|
816
|
+
.mockReturnValueOnce("trace-connect")
|
|
817
|
+
.mockReturnValueOnce("trace-aligned"),
|
|
818
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
819
|
+
heartbeat: { enabled: false },
|
|
820
|
+
});
|
|
821
|
+
await connectReady(transport, client);
|
|
822
|
+
|
|
823
|
+
const aligned = sendOpenclawClawlingText({
|
|
824
|
+
client,
|
|
825
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
826
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
827
|
+
text: "aligned",
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
transport.emitInbound(JSON.stringify({
|
|
831
|
+
version: "2",
|
|
832
|
+
event: "message.error",
|
|
833
|
+
trace_id: "trace-unmatched",
|
|
834
|
+
emitted_at: Date.now(),
|
|
835
|
+
chat_id: "missing-chat",
|
|
836
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
837
|
+
}));
|
|
838
|
+
|
|
839
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unmatched message.error"));
|
|
840
|
+
|
|
841
|
+
transport.emitInbound(JSON.stringify({
|
|
842
|
+
version: "2",
|
|
843
|
+
event: "message.ack",
|
|
844
|
+
trace_id: "trace-aligned",
|
|
845
|
+
emitted_at: Date.now(),
|
|
846
|
+
chat_id: "chat-aligned",
|
|
847
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
848
|
+
}));
|
|
849
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
850
|
+
} finally {
|
|
851
|
+
warn.mockRestore();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("does not log aligned unmatched when message.error matches a core pending send", async () => {
|
|
856
|
+
const transport = new MockTransport();
|
|
857
|
+
const client = createClawChatClient({
|
|
858
|
+
url: "ws://test",
|
|
859
|
+
token: "token-1",
|
|
860
|
+
deviceId: "agent-1",
|
|
861
|
+
transport,
|
|
862
|
+
traceIdFactory: vi.fn()
|
|
863
|
+
.mockReturnValueOnce("trace-connect")
|
|
864
|
+
.mockReturnValueOnce("trace-aligned")
|
|
865
|
+
.mockReturnValueOnce("trace-core"),
|
|
866
|
+
ack: { timeout: 1000, autoResendOnTimeout: false },
|
|
867
|
+
heartbeat: { enabled: false },
|
|
868
|
+
});
|
|
869
|
+
await connectReady(transport, client);
|
|
870
|
+
const logs: string[] = [];
|
|
871
|
+
|
|
872
|
+
const aligned = sendOpenclawClawlingText({
|
|
873
|
+
client,
|
|
874
|
+
account: baseAccount({ ack: { timeout: 1000, autoResendOnTimeout: false } }),
|
|
875
|
+
to: { chatId: "chat-aligned", chatType: "direct" },
|
|
876
|
+
text: "aligned",
|
|
877
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
878
|
+
});
|
|
879
|
+
const core = client.sendAckableEnvelope({
|
|
880
|
+
eventName: "message.send",
|
|
881
|
+
chatId: "missing-chat",
|
|
882
|
+
payload: {
|
|
883
|
+
message_mode: "normal",
|
|
884
|
+
message: {
|
|
885
|
+
body: { fragments: [{ kind: "text", text: "core" }] },
|
|
886
|
+
context: { mentions: [], reply: null },
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
transport.emitInbound(JSON.stringify({
|
|
892
|
+
version: "2",
|
|
893
|
+
event: "message.error",
|
|
894
|
+
trace_id: "trace-core",
|
|
895
|
+
emitted_at: Date.now(),
|
|
896
|
+
chat_id: "missing-chat",
|
|
897
|
+
payload: { code: "chat_not_found", message: "chat not resolvable" },
|
|
898
|
+
}));
|
|
899
|
+
|
|
900
|
+
await expect(core).rejects.toThrow(/chat_not_found.*chat not resolvable/);
|
|
901
|
+
expect(logs.some((line) => line.includes("event=ack_unmatched"))).toBe(false);
|
|
902
|
+
|
|
903
|
+
transport.emitInbound(JSON.stringify({
|
|
904
|
+
version: "2",
|
|
905
|
+
event: "message.ack",
|
|
906
|
+
trace_id: "trace-aligned",
|
|
907
|
+
emitted_at: Date.now(),
|
|
908
|
+
chat_id: "chat-aligned",
|
|
909
|
+
payload: { message_id: "server-aligned", accepted_at: 1234 },
|
|
910
|
+
}));
|
|
911
|
+
await expect(aligned).resolves.toMatchObject({ messageId: "server-aligned" });
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("suppresses send when text is empty after trim", async () => {
|
|
915
|
+
const client = mockClient();
|
|
916
|
+
const result = await sendOpenclawClawlingText({
|
|
917
|
+
client,
|
|
918
|
+
account: baseAccount(),
|
|
919
|
+
to: { chatId: "u", chatType: "direct" },
|
|
920
|
+
text: " ",
|
|
921
|
+
});
|
|
922
|
+
expect(client.sent).toHaveLength(0);
|
|
923
|
+
expect(result).toBeNull();
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("appends mediaFragments after text in body.fragments", async () => {
|
|
927
|
+
const client = mockClient();
|
|
928
|
+
const send = sendOpenclawClawlingText({
|
|
929
|
+
client,
|
|
930
|
+
account: baseAccount(),
|
|
931
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
932
|
+
text: "look",
|
|
933
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
934
|
+
});
|
|
935
|
+
const frame = decodeSent(client)[0]!;
|
|
936
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
937
|
+
{ kind: "text", text: "look" },
|
|
938
|
+
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
939
|
+
]);
|
|
940
|
+
emitAck(client, frame.trace_id);
|
|
941
|
+
await send;
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("sends media-only message when text empty but mediaFragments present", async () => {
|
|
945
|
+
const client = mockClient();
|
|
946
|
+
const send = sendOpenclawClawlingText({
|
|
947
|
+
client,
|
|
948
|
+
account: baseAccount(),
|
|
949
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
950
|
+
text: "",
|
|
951
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
952
|
+
});
|
|
953
|
+
const frame = decodeSent(client)[0]!;
|
|
954
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
955
|
+
emitAck(client, frame.trace_id);
|
|
956
|
+
const result = await send;
|
|
957
|
+
expect(result?.messageId).toBe("server-m1");
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("preserves replyCtx when media fragments are present", async () => {
|
|
961
|
+
const client = mockClient();
|
|
962
|
+
const log = { info: vi.fn(), error: vi.fn() };
|
|
963
|
+
const send = sendOpenclawClawlingText({
|
|
964
|
+
client,
|
|
965
|
+
account: baseAccount(),
|
|
966
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
967
|
+
text: "hi",
|
|
968
|
+
replyCtx: {
|
|
969
|
+
replyToMessageId: "m-orig",
|
|
970
|
+
replyPreviewSenderId: "user-2",
|
|
971
|
+
replyPreviewNickName: "Sender",
|
|
972
|
+
replyPreviewText: "original",
|
|
973
|
+
},
|
|
974
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
975
|
+
log,
|
|
976
|
+
});
|
|
977
|
+
const frame = decodeSent(client)[0]!;
|
|
978
|
+
const payload = frame.payload as {
|
|
979
|
+
message: {
|
|
980
|
+
body: { fragments: unknown[] };
|
|
981
|
+
context: { reply: { reply_to_msg_id: string; reply_preview: { id: string; nick_name: string } } };
|
|
982
|
+
};
|
|
983
|
+
};
|
|
984
|
+
expect(frame.event).toBe("message.reply");
|
|
985
|
+
expect(payload.message.context.reply).toMatchObject({
|
|
986
|
+
reply_to_msg_id: "m-orig",
|
|
987
|
+
reply_preview: { id: "user-2", nick_name: "Sender" },
|
|
988
|
+
});
|
|
989
|
+
expect(payload.message.body.fragments).toEqual([
|
|
990
|
+
{ kind: "text", text: "hi" },
|
|
991
|
+
{ kind: "image", url: "https://cdn/x.png" },
|
|
992
|
+
]);
|
|
993
|
+
emitAck(client, frame.trace_id);
|
|
994
|
+
await send;
|
|
995
|
+
expect(log.info).not.toHaveBeenCalledWith(
|
|
996
|
+
expect.stringMatching(/replyCtx \+ media: downgraded to sendMessage/),
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("suppresses send when both text and mediaFragments are empty", async () => {
|
|
1001
|
+
const client = mockClient();
|
|
1002
|
+
const result = await sendOpenclawClawlingText({
|
|
1003
|
+
client,
|
|
1004
|
+
account: baseAccount(),
|
|
1005
|
+
to: { chatId: "u", chatType: "direct" },
|
|
1006
|
+
text: " ",
|
|
1007
|
+
mediaFragments: [],
|
|
1008
|
+
});
|
|
1009
|
+
expect(client.sent).toHaveLength(0);
|
|
1010
|
+
expect(result).toBeNull();
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it("sendOpenclawClawlingMedia with image and caption sends both fragments", async () => {
|
|
1014
|
+
const client = mockClient();
|
|
1015
|
+
const send = sendOpenclawClawlingMedia({
|
|
1016
|
+
client,
|
|
1017
|
+
account: baseAccount(),
|
|
1018
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
1019
|
+
text: "look at this",
|
|
1020
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
1021
|
+
});
|
|
1022
|
+
const frame = decodeSent(client)[0]!;
|
|
1023
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([
|
|
1024
|
+
{ kind: "text", text: "look at this" },
|
|
1025
|
+
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
1026
|
+
]);
|
|
1027
|
+
emitAck(client, frame.trace_id);
|
|
1028
|
+
const result = await send;
|
|
1029
|
+
expect(result?.messageId).toBe("server-m1");
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it("sendOpenclawClawlingMedia with image only (no text) sends just the media fragment", async () => {
|
|
1033
|
+
const client = mockClient();
|
|
1034
|
+
const send = sendOpenclawClawlingMedia({
|
|
1035
|
+
client,
|
|
1036
|
+
account: baseAccount(),
|
|
1037
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
1038
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
1039
|
+
});
|
|
1040
|
+
const frame = decodeSent(client)[0]!;
|
|
1041
|
+
expect((frame.payload as { message: { body: { fragments: unknown[] } } }).message.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
1042
|
+
emitAck(client, frame.trace_id);
|
|
1043
|
+
const result = await send;
|
|
1044
|
+
expect(result?.messageId).toBe("server-m1");
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("sendOpenclawClawlingMedia returns null and does not send when mediaFragments is empty", async () => {
|
|
1048
|
+
const client = mockClient();
|
|
1049
|
+
const log = { info: vi.fn(), error: vi.fn() };
|
|
1050
|
+
const result = await sendOpenclawClawlingMedia({
|
|
1051
|
+
client,
|
|
1052
|
+
account: baseAccount(),
|
|
1053
|
+
to: { chatId: "u", chatType: "direct" },
|
|
1054
|
+
mediaFragments: [],
|
|
1055
|
+
log,
|
|
1056
|
+
});
|
|
1057
|
+
expect(client.sent).toHaveLength(0);
|
|
1058
|
+
expect(result).toBeNull();
|
|
1059
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
1060
|
+
expect.stringMatching(/sendMedia called with empty mediaFragments/),
|
|
1061
|
+
);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it("starts ack timeout only after a queued ackable frame is written", async () => {
|
|
1065
|
+
vi.useFakeTimers();
|
|
1066
|
+
const logs: string[] = [];
|
|
1067
|
+
const client = mockClient({ transportState: "closed" });
|
|
1068
|
+
|
|
1069
|
+
let rejected: unknown;
|
|
1070
|
+
const promise = sendOpenclawClawlingText({
|
|
1071
|
+
client,
|
|
1072
|
+
account: baseAccount({ ack: { timeout: 15000, autoResendOnTimeout: false } }),
|
|
1073
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1074
|
+
text: "hello",
|
|
1075
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1076
|
+
});
|
|
1077
|
+
promise.catch((err) => {
|
|
1078
|
+
rejected = err;
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
await Promise.resolve();
|
|
1082
|
+
await vi.advanceTimersByTimeAsync(15001);
|
|
1083
|
+
expect(rejected).toBeUndefined();
|
|
1084
|
+
expect(client.sent).toHaveLength(0);
|
|
1085
|
+
|
|
1086
|
+
client.setTransportState("open");
|
|
1087
|
+
flushAlignedOutboundQueue(client);
|
|
1088
|
+
expect(client.sent).toHaveLength(1);
|
|
1089
|
+
|
|
1090
|
+
await vi.advanceTimersByTimeAsync(14999);
|
|
1091
|
+
expect(rejected).toBeUndefined();
|
|
1092
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
1093
|
+
|
|
1094
|
+
await expect(promise).rejects.toThrow(/ack timeout/);
|
|
1095
|
+
expect(logs).toContain(
|
|
1096
|
+
"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",
|
|
1097
|
+
);
|
|
1098
|
+
vi.useRealTimers();
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it("rejects an ackable send when queue overflow drops it before write", async () => {
|
|
1102
|
+
const logs: string[] = [];
|
|
1103
|
+
const client = mockClient({ transportState: "closed" });
|
|
1104
|
+
const account = baseAccount();
|
|
1105
|
+
|
|
1106
|
+
const first = sendOpenclawClawlingText({
|
|
1107
|
+
client,
|
|
1108
|
+
account,
|
|
1109
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1110
|
+
text: "first",
|
|
1111
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
for (let i = 0; i < 128; i += 1) {
|
|
1115
|
+
sendOpenclawClawlingText({
|
|
1116
|
+
client,
|
|
1117
|
+
account,
|
|
1118
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1119
|
+
text: `queued-${i}`,
|
|
1120
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1121
|
+
}).catch(() => {});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
await Promise.resolve();
|
|
1125
|
+
|
|
1126
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(128);
|
|
1127
|
+
expect(client.listenerCount("close")).toBe(1);
|
|
1128
|
+
await expect(first).rejects.toThrow(/send queue full/);
|
|
1129
|
+
expect(logs.some((line) => line.includes("event=send_queue_drop"))).toBe(true);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it("cancels a queued ackable send on terminal close without later flushing it", async () => {
|
|
1133
|
+
const client = mockClient({ transportState: "closed" });
|
|
1134
|
+
const send = sendOpenclawClawlingText({
|
|
1135
|
+
client,
|
|
1136
|
+
account: baseAccount(),
|
|
1137
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1138
|
+
text: "hello",
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
await Promise.resolve();
|
|
1142
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
1143
|
+
|
|
1144
|
+
client.emit("close", { code: 1000, reason: "client close" });
|
|
1145
|
+
|
|
1146
|
+
await expect(send).rejects.toThrow(/send cancelled because client close/);
|
|
1147
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
1148
|
+
|
|
1149
|
+
client.setTransportState("open");
|
|
1150
|
+
flushAlignedOutboundQueue(client);
|
|
1151
|
+
expect(client.sent).toHaveLength(0);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it("cancels a queued ackable send on disconnected state without close event", async () => {
|
|
1155
|
+
const client = mockClient({ transportState: "closed" });
|
|
1156
|
+
const send = sendOpenclawClawlingText({
|
|
1157
|
+
client,
|
|
1158
|
+
account: baseAccount(),
|
|
1159
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1160
|
+
text: "hello",
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
await Promise.resolve();
|
|
1164
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
1165
|
+
|
|
1166
|
+
client.emit("state", { from: "connected", to: "disconnected" });
|
|
1167
|
+
|
|
1168
|
+
await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
|
|
1169
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
1170
|
+
|
|
1171
|
+
client.setTransportState("open");
|
|
1172
|
+
flushAlignedOutboundQueue(client);
|
|
1173
|
+
expect(client.sent).toHaveLength(0);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it("rejects a new ackable send when the client is already disconnected", async () => {
|
|
1177
|
+
const client = mockClient({ transportState: "closed", state: "disconnected" });
|
|
1178
|
+
const send = sendOpenclawClawlingText({
|
|
1179
|
+
client,
|
|
1180
|
+
account: baseAccount(),
|
|
1181
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1182
|
+
text: "hello",
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
await expect(send).rejects.toThrow(/send cancelled because client disconnected/);
|
|
1186
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
1187
|
+
expect(client.sent).toHaveLength(0);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("keeps ackable send pending when a ready-state write fails and resolves after retry", async () => {
|
|
1191
|
+
const client = mockClient();
|
|
1192
|
+
let attempts = 0;
|
|
1193
|
+
client.sendWire = vi.fn((wire: string) => {
|
|
1194
|
+
attempts += 1;
|
|
1195
|
+
if (attempts === 1) throw new Error("socket closed");
|
|
1196
|
+
client.sent.push(wire);
|
|
1197
|
+
}) as ClawlingChatClient["sendWire"];
|
|
1198
|
+
|
|
1199
|
+
const send = sendOpenclawClawlingText({
|
|
1200
|
+
client,
|
|
1201
|
+
account: baseAccount(),
|
|
1202
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1203
|
+
text: "hello",
|
|
1204
|
+
});
|
|
1205
|
+
const observed = send.then(
|
|
1206
|
+
(result) => ({ status: "resolved" as const, result }),
|
|
1207
|
+
(err: unknown) => ({ status: "rejected" as const, err }),
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
await Promise.resolve();
|
|
1211
|
+
|
|
1212
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
1213
|
+
expect(client.sent).toHaveLength(0);
|
|
1214
|
+
|
|
1215
|
+
flushAlignedOutboundQueue(client);
|
|
1216
|
+
const frame = decodeSent(client)[0]!;
|
|
1217
|
+
emitAck(client, frame.trace_id, { message_id: "server-retry", accepted_at: 1234 });
|
|
1218
|
+
|
|
1219
|
+
await expect(observed).resolves.toMatchObject({
|
|
1220
|
+
status: "resolved",
|
|
1221
|
+
result: { messageId: "server-retry", acceptedAt: 1234 },
|
|
1222
|
+
});
|
|
1223
|
+
expect(client.sendWire).toHaveBeenCalledTimes(2);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("requeues a written ackable frame on disconnect before ack", async () => {
|
|
1227
|
+
const logs: string[] = [];
|
|
1228
|
+
const client = mockClient();
|
|
1229
|
+
|
|
1230
|
+
const send = sendOpenclawClawlingText({
|
|
1231
|
+
client,
|
|
1232
|
+
account: baseAccount(),
|
|
1233
|
+
to: { chatId: "chat-1", chatType: "direct" },
|
|
1234
|
+
text: "hello",
|
|
1235
|
+
messageId: "local-msg-1",
|
|
1236
|
+
log: { info: (msg) => logs.push(msg), error: (msg) => logs.push(msg) },
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
await Promise.resolve();
|
|
1240
|
+
const firstFrame = decodeSent(client)[0]!;
|
|
1241
|
+
expect(firstFrame.event).toBe("message.send");
|
|
1242
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(0);
|
|
1243
|
+
|
|
1244
|
+
client.setTransportState("closed");
|
|
1245
|
+
client.emit("close", { code: 1006, reason: "network lost" });
|
|
1246
|
+
|
|
1247
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
1248
|
+
|
|
1249
|
+
client.setTransportState("open");
|
|
1250
|
+
flushAlignedOutboundQueue(client);
|
|
1251
|
+
|
|
1252
|
+
const frames = decodeSent(client);
|
|
1253
|
+
expect(frames).toHaveLength(2);
|
|
1254
|
+
expect(frames[1]).toMatchObject({
|
|
1255
|
+
event: firstFrame.event,
|
|
1256
|
+
trace_id: firstFrame.trace_id,
|
|
1257
|
+
chat_id: firstFrame.chat_id,
|
|
1258
|
+
payload: firstFrame.payload,
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
emitAck(client, firstFrame.trace_id, { message_id: "local-msg-1", accepted_at: 2222 });
|
|
1262
|
+
|
|
1263
|
+
await expect(send).resolves.toMatchObject({
|
|
1264
|
+
messageId: "local-msg-1",
|
|
1265
|
+
acceptedAt: 2222,
|
|
1266
|
+
});
|
|
1267
|
+
expect(logs.some((line) => line.includes("event=send_queued") && line.includes(`trace_id=${firstFrame.trace_id}`))).toBe(true);
|
|
1268
|
+
});
|
|
1269
|
+
});
|