@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +64 -0
- package/README.md +121 -19
- package/dist/index.js +10 -19
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +78 -10
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +25 -156
- package/dist/src/channel.setup.js +120 -0
- package/dist/src/client.js +37 -41
- package/dist/src/config.js +75 -17
- package/dist/src/inbound.js +79 -61
- package/dist/src/login.runtime.js +84 -19
- package/dist/src/media-runtime.js +8 -8
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +410 -26
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -7
- package/dist/src/reply-dispatcher.js +157 -54
- package/dist/src/runtime.js +795 -119
- package/dist/src/storage.js +689 -0
- package/dist/src/tools-schema.js +98 -16
- package/dist/src/tools.js +422 -135
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +10 -22
- package/openclaw.plugin.json +37 -2
- package/package.json +17 -4
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +88 -0
- package/src/api-client.test.ts +274 -14
- package/src/api-client.ts +138 -23
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +90 -4
- package/src/buffered-stream.test.ts +14 -12
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +269 -60
- package/src/channel.setup.ts +146 -0
- package/src/channel.test.ts +130 -24
- package/src/channel.ts +30 -186
- package/src/client.test.ts +197 -11
- package/src/client.ts +50 -57
- package/src/config.test.ts +108 -6
- package/src/config.ts +95 -24
- package/src/inbound.test.ts +288 -37
- package/src/inbound.ts +96 -84
- package/src/login.runtime.test.ts +347 -13
- package/src/login.runtime.ts +105 -23
- package/src/manifest.test.ts +146 -74
- package/src/media-runtime.test.ts +57 -2
- package/src/media-runtime.ts +26 -17
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +694 -73
- package/src/outbound.ts +484 -31
- package/src/plugin-entry.test.ts +1 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +1 -6
- package/src/protocol.ts +2 -7
- package/src/reply-dispatcher.test.ts +819 -119
- package/src/reply-dispatcher.ts +202 -60
- package/src/runtime.test.ts +2120 -41
- package/src/runtime.ts +935 -142
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +793 -0
- package/src/storage.ts +1095 -0
- package/src/streaming.test.ts +9 -8
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +148 -20
- package/src/tools.test.ts +377 -50
- package/src/tools.ts +574 -154
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
- package/skills/clawchat-account-tools/SKILL.md +0 -26
- package/skills/clawchat-activate/SKILL.md +0 -47
|
@@ -1,9 +1,617 @@
|
|
|
1
|
-
import type { ClawlingChatClient } from "
|
|
1
|
+
import type { ClawlingChatClient } from "./ws-client.ts";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
|
|
5
|
+
import {
|
|
6
|
+
flushAlignedOutboundQueue,
|
|
7
|
+
getAlignedOutboundQueueSize,
|
|
8
|
+
} from "./outbound.ts";
|
|
9
|
+
|
|
10
|
+
type SentFrame = {
|
|
11
|
+
event: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
chat_id?: string;
|
|
14
|
+
trace_id?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type TestClient = ClawlingChatClient & {
|
|
18
|
+
sent: SentFrame[];
|
|
19
|
+
typing: ReturnType<typeof vi.fn>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function mockClient(
|
|
23
|
+
sent: SentFrame[] = [],
|
|
24
|
+
options: { transportState?: "open" | "closed"; state?: string; autoAck?: boolean } = {},
|
|
25
|
+
): TestClient & { setTransportState: (state: "open" | "closed") => void; setState: (state: string) => void } {
|
|
26
|
+
let trace = 0;
|
|
27
|
+
let transportState = options.transportState ?? "open";
|
|
28
|
+
let clientState = options.state ?? "connected";
|
|
29
|
+
const autoAck = options.autoAck ?? true;
|
|
30
|
+
const client = Object.assign(new EventEmitter(), {
|
|
31
|
+
sent,
|
|
32
|
+
nextTraceId: vi.fn(() => `trace-${++trace}`),
|
|
33
|
+
sendWire: vi.fn((wire: string) => {
|
|
34
|
+
const env = JSON.parse(wire) as SentFrame & { trace_id?: string };
|
|
35
|
+
sent.push({ event: env.event, payload: env.payload, chat_id: env.chat_id, trace_id: env.trace_id });
|
|
36
|
+
if (autoAck && (env.event === "message.send" || env.event === "message.reply")) {
|
|
37
|
+
const payload = env.payload as { message_id?: string };
|
|
38
|
+
queueMicrotask(() => {
|
|
39
|
+
client.emit("raw", {
|
|
40
|
+
version: "2",
|
|
41
|
+
event: "message.ack",
|
|
42
|
+
trace_id: env.trace_id,
|
|
43
|
+
emitted_at: Date.now(),
|
|
44
|
+
payload: { message_id: payload.message_id ?? "server-m1", accepted_at: 1234 },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
emitRaw: vi.fn((event: string, payload: Record<string, unknown>, routing?: { chat_id?: string }) => {
|
|
50
|
+
sent.push({ event, payload, chat_id: routing?.chat_id });
|
|
51
|
+
}),
|
|
52
|
+
sendRawEnvelope: vi.fn((env: { event: string; payload: Record<string, unknown>; chat_id?: string; trace_id?: string }) => {
|
|
53
|
+
sent.push({ event: env.event, payload: env.payload, chat_id: env.chat_id, trace_id: env.trace_id });
|
|
54
|
+
}),
|
|
55
|
+
typing: vi.fn(),
|
|
56
|
+
setTransportState: (state: "open" | "closed") => {
|
|
57
|
+
transportState = state;
|
|
58
|
+
},
|
|
59
|
+
setState: (state: string) => {
|
|
60
|
+
clientState = state;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
Object.defineProperty(client, "state", { get: () => clientState });
|
|
64
|
+
Object.defineProperty(client, "transportState", { get: () => transportState });
|
|
65
|
+
return client as unknown as TestClient & {
|
|
66
|
+
setTransportState: (state: "open" | "closed") => void;
|
|
67
|
+
setState: (state: string) => void;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runtimeWithHooks(setHooks: (hooks: Record<string, unknown>) => void) {
|
|
72
|
+
return {
|
|
73
|
+
channel: {
|
|
74
|
+
reply: {
|
|
75
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
76
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
77
|
+
setHooks(options as Record<string, unknown>);
|
|
78
|
+
return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
} as never;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function replyAccount(overrides: Record<string, unknown> = {}) {
|
|
86
|
+
return {
|
|
87
|
+
accountId: "default",
|
|
88
|
+
userId: "agent-1",
|
|
89
|
+
replyMode: "static",
|
|
90
|
+
forwardThinking: true,
|
|
91
|
+
forwardToolCalls: false,
|
|
92
|
+
richInteractions: false,
|
|
93
|
+
stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
|
|
94
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
95
|
+
...overrides,
|
|
96
|
+
} as never;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type TestStore = {
|
|
100
|
+
claimMessageOnce?: (input: unknown) => true | false | null;
|
|
101
|
+
updateMessageByIdentity?: (input: unknown) => void;
|
|
102
|
+
insertMessage?: (input: unknown) => unknown;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
async function runStaticReplyWithStore(store: TestStore) {
|
|
106
|
+
let hooks: Record<string, unknown> = {};
|
|
107
|
+
const sent: SentFrame[] = [];
|
|
108
|
+
const client = mockClient(sent);
|
|
109
|
+
|
|
110
|
+
createOpenclawClawlingReplyDispatcher({
|
|
111
|
+
cfg: {} as never,
|
|
112
|
+
runtime: runtimeWithHooks((next) => {
|
|
113
|
+
hooks = next;
|
|
114
|
+
}),
|
|
115
|
+
account: replyAccount(),
|
|
116
|
+
client,
|
|
117
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
118
|
+
store: store as never,
|
|
119
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
123
|
+
{ text: "static reply" },
|
|
124
|
+
{ kind: "final" },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
queuedFinal: sent.some((entry) => entry.event === "message.send" || entry.event === "message.reply"),
|
|
129
|
+
sent,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function runStreamingReplyWithStore(store: TestStore, chunks: string[]) {
|
|
134
|
+
let hooks: Record<string, unknown> = {};
|
|
135
|
+
const sent: SentFrame[] = [];
|
|
136
|
+
const client = mockClient(sent);
|
|
137
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
138
|
+
cfg: {} as never,
|
|
139
|
+
runtime: runtimeWithHooks((next) => {
|
|
140
|
+
hooks = next;
|
|
141
|
+
}),
|
|
142
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
143
|
+
client,
|
|
144
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
145
|
+
inboundMessageId: "inbound-1",
|
|
146
|
+
inboundForFinalReply: {
|
|
147
|
+
chatId: "chat-1",
|
|
148
|
+
senderId: "user-1",
|
|
149
|
+
senderNickName: "User 1",
|
|
150
|
+
bodyText: "hello",
|
|
151
|
+
},
|
|
152
|
+
store: store as never,
|
|
153
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await (hooks.onReplyStart as (() => Promise<void>) | undefined)?.();
|
|
157
|
+
if (chunks[0]) {
|
|
158
|
+
await created.replyOptions.onPartialReply?.({ text: chunks[0] });
|
|
159
|
+
}
|
|
160
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
161
|
+
{ text: chunks.at(-1) ?? "" },
|
|
162
|
+
{ kind: "final" },
|
|
163
|
+
);
|
|
164
|
+
await (hooks.onIdle as () => Promise<void>)();
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
sent,
|
|
168
|
+
streamingMessageId: sent.find((entry) => entry.event === "message.created")?.payload.message_id,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
4
171
|
|
|
5
172
|
describe("openclaw-clawchat reply-dispatcher", () => {
|
|
6
|
-
it("
|
|
173
|
+
it("claims static outbound before send and uses the claimed payload message_id", async () => {
|
|
174
|
+
const store = {
|
|
175
|
+
claimMessageOnce: vi.fn(() => true),
|
|
176
|
+
insertMessage: vi.fn(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const result = await runStaticReplyWithStore(store);
|
|
180
|
+
const messageId = store.claimMessageOnce.mock.calls[0]![0].messageId;
|
|
181
|
+
|
|
182
|
+
expect(messageId).toEqual(expect.any(String));
|
|
183
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
184
|
+
direction: "outbound",
|
|
185
|
+
kind: "message",
|
|
186
|
+
messageId,
|
|
187
|
+
}));
|
|
188
|
+
expect(result.sent[0]?.payload.message_id).toBe(messageId);
|
|
189
|
+
expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("does not send static outbound when the storage claim is duplicate or unavailable", async () => {
|
|
193
|
+
const duplicateStore = { claimMessageOnce: vi.fn(() => false), insertMessage: vi.fn() };
|
|
194
|
+
const unavailableStore = { claimMessageOnce: vi.fn(() => null), insertMessage: vi.fn() };
|
|
195
|
+
|
|
196
|
+
await expect(runStaticReplyWithStore(duplicateStore)).resolves.toMatchObject({ queuedFinal: false });
|
|
197
|
+
await expect(runStaticReplyWithStore(unavailableStore)).resolves.toMatchObject({ queuedFinal: false });
|
|
198
|
+
expect(duplicateStore.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
199
|
+
direction: "outbound",
|
|
200
|
+
kind: "message",
|
|
201
|
+
}));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("claims static forwarded thinking before sending", async () => {
|
|
205
|
+
let hooks: Record<string, unknown> = {};
|
|
206
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
207
|
+
const sent: SentFrame[] = [];
|
|
208
|
+
const client = mockClient(sent);
|
|
209
|
+
|
|
210
|
+
createOpenclawClawlingReplyDispatcher({
|
|
211
|
+
cfg: {} as never,
|
|
212
|
+
runtime: runtimeWithHooks((next) => {
|
|
213
|
+
hooks = next;
|
|
214
|
+
}),
|
|
215
|
+
account: replyAccount({ replyMode: "static", forwardThinking: true }),
|
|
216
|
+
client,
|
|
217
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
218
|
+
store: store as never,
|
|
219
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
|
|
223
|
+
{ text: "thinking text", isReasoning: true },
|
|
224
|
+
);
|
|
225
|
+
const messageId = store.claimMessageOnce.mock.calls[0]?.[0].messageId;
|
|
226
|
+
|
|
227
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
228
|
+
kind: "message",
|
|
229
|
+
direction: "outbound",
|
|
230
|
+
eventType: "message.send",
|
|
231
|
+
messageId,
|
|
232
|
+
text: "thinking text",
|
|
233
|
+
}));
|
|
234
|
+
expect(sent[0]?.payload.message_id).toBe(messageId);
|
|
235
|
+
expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
236
|
+
kind: "thinking",
|
|
237
|
+
messageId,
|
|
238
|
+
text: "thinking text",
|
|
239
|
+
}));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does not send static forwarded thinking when the storage claim is duplicate or unavailable", async () => {
|
|
243
|
+
for (const claimResult of [false, null] as const) {
|
|
244
|
+
let hooks: Record<string, unknown> = {};
|
|
245
|
+
const store = { claimMessageOnce: vi.fn(() => claimResult), insertMessage: vi.fn() };
|
|
246
|
+
const sent: SentFrame[] = [];
|
|
247
|
+
const client = mockClient(sent);
|
|
248
|
+
|
|
249
|
+
createOpenclawClawlingReplyDispatcher({
|
|
250
|
+
cfg: {} as never,
|
|
251
|
+
runtime: runtimeWithHooks((next) => {
|
|
252
|
+
hooks = next;
|
|
253
|
+
}),
|
|
254
|
+
account: replyAccount({ replyMode: "static", forwardThinking: true }),
|
|
255
|
+
client,
|
|
256
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
257
|
+
store: store as never,
|
|
258
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
|
|
262
|
+
{ text: "thinking text", isReasoning: true },
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
266
|
+
kind: "message",
|
|
267
|
+
direction: "outbound",
|
|
268
|
+
}));
|
|
269
|
+
expect(sent).toHaveLength(0);
|
|
270
|
+
expect(store.insertMessage).not.toHaveBeenCalled();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("claims a streaming outbound message once and updates the row on final reply", async () => {
|
|
275
|
+
const store = {
|
|
276
|
+
claimMessageOnce: vi.fn(() => true),
|
|
277
|
+
updateMessageByIdentity: vi.fn(),
|
|
278
|
+
insertMessage: vi.fn(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const result = await runStreamingReplyWithStore(store, ["hello", "hello final"]);
|
|
282
|
+
|
|
283
|
+
expect(result.streamingMessageId).toEqual(expect.any(String));
|
|
284
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
286
|
+
direction: "outbound",
|
|
287
|
+
eventType: "message.created",
|
|
288
|
+
messageId: result.streamingMessageId,
|
|
289
|
+
}));
|
|
290
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
291
|
+
direction: "outbound",
|
|
292
|
+
eventType: "message.reply",
|
|
293
|
+
messageId: result.streamingMessageId,
|
|
294
|
+
text: "hello final",
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("queues the consolidated streaming final reply while disconnected and flushes it after reconnect", async () => {
|
|
299
|
+
let hooks: Record<string, unknown> = {};
|
|
300
|
+
const sent: SentFrame[] = [];
|
|
301
|
+
const client = mockClient(sent, { transportState: "closed", state: "reconnecting" });
|
|
302
|
+
const store = {
|
|
303
|
+
claimMessageOnce: vi.fn(() => true),
|
|
304
|
+
updateMessageByIdentity: vi.fn(),
|
|
305
|
+
insertMessage: vi.fn(),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
createOpenclawClawlingReplyDispatcher({
|
|
309
|
+
cfg: {} as never,
|
|
310
|
+
runtime: runtimeWithHooks((next) => {
|
|
311
|
+
hooks = next;
|
|
312
|
+
}),
|
|
313
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
314
|
+
client,
|
|
315
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
316
|
+
inboundMessageId: "inbound-1",
|
|
317
|
+
inboundForFinalReply: {
|
|
318
|
+
chatId: "chat-1",
|
|
319
|
+
senderId: "user-1",
|
|
320
|
+
senderNickName: "User 1",
|
|
321
|
+
bodyText: "hello",
|
|
322
|
+
},
|
|
323
|
+
store: store as never,
|
|
324
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
328
|
+
{ text: "final answer" },
|
|
329
|
+
{ kind: "final" },
|
|
330
|
+
);
|
|
331
|
+
const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
332
|
+
expect(streamingMessageId).toEqual(expect.any(String));
|
|
333
|
+
|
|
334
|
+
const idle = (hooks.onIdle as () => Promise<void>)();
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
336
|
+
|
|
337
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
338
|
+
expect(sent.filter((entry) => entry.event === "message.reply")).toHaveLength(0);
|
|
339
|
+
|
|
340
|
+
client.setTransportState("open");
|
|
341
|
+
client.setState("connected");
|
|
342
|
+
flushAlignedOutboundQueue(client);
|
|
343
|
+
await idle;
|
|
344
|
+
|
|
345
|
+
const finalReply = sent.find((entry) => entry.event === "message.reply");
|
|
346
|
+
expect(finalReply?.payload.message_id).toBe(streamingMessageId);
|
|
347
|
+
expect(finalReply?.payload).toMatchObject({
|
|
348
|
+
message: {
|
|
349
|
+
body: { fragments: [{ kind: "text", text: "final answer" }] },
|
|
350
|
+
context: {
|
|
351
|
+
reply: {
|
|
352
|
+
reply_to_msg_id: "inbound-1",
|
|
353
|
+
reply_preview: {
|
|
354
|
+
id: "user-1",
|
|
355
|
+
nick_name: "User 1",
|
|
356
|
+
fragments: [{ kind: "text", text: "hello" }],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
363
|
+
eventType: "message.reply",
|
|
364
|
+
messageId: streamingMessageId,
|
|
365
|
+
text: "final answer",
|
|
366
|
+
}));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("requeues only the consolidated streaming final reply after disconnect before ack", async () => {
|
|
370
|
+
let hooks: Record<string, unknown> = {};
|
|
371
|
+
const sent: SentFrame[] = [];
|
|
372
|
+
const client = mockClient(sent, { autoAck: false });
|
|
373
|
+
const store = {
|
|
374
|
+
claimMessageOnce: vi.fn(() => true),
|
|
375
|
+
updateMessageByIdentity: vi.fn(),
|
|
376
|
+
insertMessage: vi.fn(),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
createOpenclawClawlingReplyDispatcher({
|
|
380
|
+
cfg: {} as never,
|
|
381
|
+
runtime: runtimeWithHooks((next) => {
|
|
382
|
+
hooks = next;
|
|
383
|
+
}),
|
|
384
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
385
|
+
client,
|
|
386
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
387
|
+
inboundMessageId: "inbound-1",
|
|
388
|
+
inboundForFinalReply: {
|
|
389
|
+
chatId: "chat-1",
|
|
390
|
+
senderId: "user-1",
|
|
391
|
+
senderNickName: "User 1",
|
|
392
|
+
bodyText: "hello",
|
|
393
|
+
},
|
|
394
|
+
store,
|
|
395
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
399
|
+
{ text: "final answer" },
|
|
400
|
+
{ kind: "final" },
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const idle = (hooks.onIdle as () => Promise<void>)();
|
|
404
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
405
|
+
|
|
406
|
+
const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
407
|
+
const firstFinalReply = sent.find((entry) => entry.event === "message.reply");
|
|
408
|
+
expect(streamingMessageId).toEqual(expect.any(String));
|
|
409
|
+
expect(firstFinalReply?.payload.message_id).toBe(streamingMessageId);
|
|
410
|
+
expect(firstFinalReply?.trace_id).toEqual(expect.any(String));
|
|
411
|
+
|
|
412
|
+
const lifecycleFramesBeforeReconnect = sent.filter((entry) =>
|
|
413
|
+
entry.event === "message.created" || entry.event === "message.add" || entry.event === "message.done"
|
|
414
|
+
);
|
|
415
|
+
const lifecycleCountsBeforeReconnect = {
|
|
416
|
+
created: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.created").length,
|
|
417
|
+
add: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.add").length,
|
|
418
|
+
done: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.done").length,
|
|
419
|
+
};
|
|
420
|
+
expect(lifecycleCountsBeforeReconnect.created).toBe(1);
|
|
421
|
+
expect(lifecycleCountsBeforeReconnect.add).toBeGreaterThanOrEqual(1);
|
|
422
|
+
expect(lifecycleCountsBeforeReconnect.done).toBe(1);
|
|
423
|
+
expect(lifecycleFramesBeforeReconnect.every((entry) => entry.payload.message_id === streamingMessageId)).toBe(true);
|
|
424
|
+
|
|
425
|
+
client.setTransportState("closed");
|
|
426
|
+
client.emit("close", { code: 1006, reason: "network lost" });
|
|
427
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
428
|
+
|
|
429
|
+
client.setTransportState("open");
|
|
430
|
+
client.setState("connected");
|
|
431
|
+
flushAlignedOutboundQueue(client);
|
|
432
|
+
|
|
433
|
+
expect({
|
|
434
|
+
created: sent.filter((entry) => entry.event === "message.created").length,
|
|
435
|
+
add: sent.filter((entry) => entry.event === "message.add").length,
|
|
436
|
+
done: sent.filter((entry) => entry.event === "message.done").length,
|
|
437
|
+
}).toEqual(lifecycleCountsBeforeReconnect);
|
|
438
|
+
|
|
439
|
+
const finalReplies = sent.filter((entry) => entry.event === "message.reply");
|
|
440
|
+
expect(finalReplies).toHaveLength(2);
|
|
441
|
+
expect(finalReplies[1]?.trace_id).toBe(firstFinalReply?.trace_id);
|
|
442
|
+
expect(finalReplies[1]?.payload.message_id).toBe(streamingMessageId);
|
|
443
|
+
|
|
444
|
+
client.emit("raw", {
|
|
445
|
+
version: "2",
|
|
446
|
+
event: "message.ack",
|
|
447
|
+
trace_id: firstFinalReply?.trace_id,
|
|
448
|
+
emitted_at: Date.now(),
|
|
449
|
+
payload: { message_id: streamingMessageId, accepted_at: 5678 },
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await idle;
|
|
453
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
454
|
+
eventType: "message.reply",
|
|
455
|
+
messageId: streamingMessageId,
|
|
456
|
+
text: "final answer",
|
|
457
|
+
}));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("keeps thinking rows separate from outbound message claims", async () => {
|
|
461
|
+
let hooks: Record<string, unknown> = {};
|
|
462
|
+
const store = {
|
|
463
|
+
claimMessageOnce: vi.fn(() => true),
|
|
464
|
+
updateMessageByIdentity: vi.fn(),
|
|
465
|
+
insertMessage: vi.fn(),
|
|
466
|
+
};
|
|
467
|
+
const sent: SentFrame[] = [];
|
|
468
|
+
const client = mockClient(sent);
|
|
469
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
470
|
+
cfg: {} as never,
|
|
471
|
+
runtime: runtimeWithHooks((next) => {
|
|
472
|
+
hooks = next;
|
|
473
|
+
}),
|
|
474
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
475
|
+
client,
|
|
476
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
477
|
+
inboundMessageId: "inbound-1",
|
|
478
|
+
inboundForFinalReply: {
|
|
479
|
+
chatId: "chat-1",
|
|
480
|
+
senderId: "user-1",
|
|
481
|
+
senderNickName: "User 1",
|
|
482
|
+
bodyText: "hello",
|
|
483
|
+
},
|
|
484
|
+
store: store as never,
|
|
485
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await created.replyOptions.onReasoningStream?.({ text: "thinking out loud" });
|
|
489
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
490
|
+
{ text: "final answer" },
|
|
491
|
+
{ kind: "final" },
|
|
492
|
+
);
|
|
493
|
+
await (hooks.onIdle as () => Promise<void>)();
|
|
494
|
+
const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
495
|
+
|
|
496
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
497
|
+
expect(store.insertMessage).toHaveBeenCalledTimes(1);
|
|
498
|
+
expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
499
|
+
kind: "thinking",
|
|
500
|
+
messageId,
|
|
501
|
+
text: "thinking out loud",
|
|
502
|
+
}));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("does not append a static message row after the pre-send claim", async () => {
|
|
506
|
+
let hooks: Record<string, unknown> = {};
|
|
507
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
508
|
+
const client = mockClient();
|
|
509
|
+
|
|
510
|
+
createOpenclawClawlingReplyDispatcher({
|
|
511
|
+
cfg: {} as never,
|
|
512
|
+
runtime: runtimeWithHooks((next) => {
|
|
513
|
+
hooks = next;
|
|
514
|
+
}),
|
|
515
|
+
account: replyAccount(),
|
|
516
|
+
client,
|
|
517
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
518
|
+
store,
|
|
519
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
523
|
+
{ text: "static reply" },
|
|
524
|
+
{ kind: "final" },
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
528
|
+
kind: "message",
|
|
529
|
+
direction: "outbound",
|
|
530
|
+
eventType: "message.send",
|
|
531
|
+
text: "static reply",
|
|
532
|
+
}));
|
|
533
|
+
expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("persists streaming final outbound message and thinking with the same final message id", async () => {
|
|
537
|
+
let hooks: Record<string, unknown> = {};
|
|
538
|
+
const store = {
|
|
539
|
+
claimMessageOnce: vi.fn(() => true),
|
|
540
|
+
updateMessageByIdentity: vi.fn(),
|
|
541
|
+
insertMessage: vi.fn(),
|
|
542
|
+
};
|
|
543
|
+
const sent: SentFrame[] = [];
|
|
544
|
+
const client = mockClient(sent);
|
|
545
|
+
|
|
546
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
547
|
+
cfg: {} as never,
|
|
548
|
+
runtime: runtimeWithHooks((next) => {
|
|
549
|
+
hooks = next;
|
|
550
|
+
}),
|
|
551
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
552
|
+
client,
|
|
553
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
554
|
+
inboundMessageId: "inbound-1",
|
|
555
|
+
inboundForFinalReply: {
|
|
556
|
+
chatId: "chat-1",
|
|
557
|
+
senderId: "user-1",
|
|
558
|
+
senderNickName: "User 1",
|
|
559
|
+
bodyText: "hello",
|
|
560
|
+
},
|
|
561
|
+
store,
|
|
562
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
await created.replyOptions.onReasoningStream?.({
|
|
566
|
+
text: "reasoning text",
|
|
567
|
+
});
|
|
568
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
569
|
+
{ text: "final answer" },
|
|
570
|
+
{ kind: "final" },
|
|
571
|
+
);
|
|
572
|
+
await (hooks.onIdle as () => Promise<void>)();
|
|
573
|
+
|
|
574
|
+
expect(sent.map((entry) => entry.event)).toEqual([
|
|
575
|
+
"message.created",
|
|
576
|
+
"message.add",
|
|
577
|
+
"message.add",
|
|
578
|
+
"message.done",
|
|
579
|
+
"message.reply",
|
|
580
|
+
]);
|
|
581
|
+
const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
582
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
583
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
584
|
+
platform: "openclaw",
|
|
585
|
+
accountId: "default",
|
|
586
|
+
kind: "message",
|
|
587
|
+
direction: "outbound",
|
|
588
|
+
eventType: "message.created",
|
|
589
|
+
chatId: "chat-1",
|
|
590
|
+
messageId,
|
|
591
|
+
}));
|
|
592
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
593
|
+
kind: "message",
|
|
594
|
+
direction: "outbound",
|
|
595
|
+
eventType: "message.reply",
|
|
596
|
+
messageId,
|
|
597
|
+
text: "final answer",
|
|
598
|
+
}));
|
|
599
|
+
expect(store.insertMessage).toHaveBeenCalledTimes(1);
|
|
600
|
+
const thinkingRow = store.insertMessage.mock.calls[0]![0];
|
|
601
|
+
expect(thinkingRow).toMatchObject({
|
|
602
|
+
platform: "openclaw",
|
|
603
|
+
accountId: "default",
|
|
604
|
+
kind: "thinking",
|
|
605
|
+
direction: "outbound",
|
|
606
|
+
eventType: "message.send",
|
|
607
|
+
chatId: "chat-1",
|
|
608
|
+
text: "reasoning text",
|
|
609
|
+
});
|
|
610
|
+
expect(thinkingRow.messageId).toBe(messageId);
|
|
611
|
+
expect(messageId).toEqual(expect.any(String));
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("uses the inbound sender id in consolidated streaming reply previews", async () => {
|
|
7
615
|
let hooks:
|
|
8
616
|
| {
|
|
9
617
|
deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
@@ -11,18 +619,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
11
619
|
}
|
|
12
620
|
| undefined;
|
|
13
621
|
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
14
|
-
const client =
|
|
15
|
-
|
|
16
|
-
transport: {
|
|
17
|
-
send: (data: string) => {
|
|
18
|
-
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
19
|
-
sent.push({ event: env.event, payload: env.payload });
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
traceIdFactory: () => "trace-chat-marker",
|
|
23
|
-
},
|
|
24
|
-
typing: vi.fn(),
|
|
25
|
-
} as unknown as ClawlingChatClient;
|
|
622
|
+
const client = mockClient(sent);
|
|
623
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
26
624
|
|
|
27
625
|
createOpenclawClawlingReplyDispatcher({
|
|
28
626
|
cfg: {} as never,
|
|
@@ -58,6 +656,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
58
656
|
senderNickName: "User 1",
|
|
59
657
|
bodyText: "hello",
|
|
60
658
|
},
|
|
659
|
+
store: store as never,
|
|
61
660
|
log: { info: vi.fn(), error: vi.fn() },
|
|
62
661
|
});
|
|
63
662
|
|
|
@@ -70,7 +669,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
70
669
|
context: {
|
|
71
670
|
reply: {
|
|
72
671
|
reply_preview: {
|
|
73
|
-
id: "
|
|
672
|
+
id: "user-1",
|
|
74
673
|
nick_name: "User 1",
|
|
75
674
|
},
|
|
76
675
|
},
|
|
@@ -79,7 +678,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
79
678
|
});
|
|
80
679
|
});
|
|
81
680
|
|
|
82
|
-
it("emits message.failed in stream mode even if execution errors before any stream chunk", async () => {
|
|
681
|
+
it("emits sanitized message.failed in stream mode even if execution errors before any stream chunk", async () => {
|
|
83
682
|
let hooks:
|
|
84
683
|
| {
|
|
85
684
|
deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
@@ -88,18 +687,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
88
687
|
}
|
|
89
688
|
| undefined;
|
|
90
689
|
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
91
|
-
const client =
|
|
92
|
-
|
|
93
|
-
transport: {
|
|
94
|
-
send: (data: string) => {
|
|
95
|
-
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
96
|
-
sent.push({ event: env.event, payload: env.payload });
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
traceIdFactory: () => "trace-1",
|
|
100
|
-
},
|
|
101
|
-
typing: vi.fn(),
|
|
102
|
-
} as unknown as ClawlingChatClient;
|
|
690
|
+
const client = mockClient(sent);
|
|
691
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
103
692
|
|
|
104
693
|
createOpenclawClawlingReplyDispatcher({
|
|
105
694
|
cfg: {} as never,
|
|
@@ -135,7 +724,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
135
724
|
|
|
136
725
|
expect(sent).toHaveLength(1);
|
|
137
726
|
expect(sent[0]!.event).toBe("message.failed");
|
|
138
|
-
expect(sent[0]!.payload
|
|
727
|
+
expect(sent[0]!.payload).not.toHaveProperty("reason");
|
|
728
|
+
expect(sent[0]!.payload.fragments).toEqual([
|
|
729
|
+
{ kind: "text", text: "OpenClaw could not complete this reply." },
|
|
730
|
+
]);
|
|
731
|
+
expect(JSON.stringify(sent[0]!.payload)).not.toContain("boom");
|
|
139
732
|
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
140
733
|
["chat-1", false],
|
|
141
734
|
]);
|
|
@@ -150,18 +743,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
150
743
|
}
|
|
151
744
|
| undefined;
|
|
152
745
|
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
153
|
-
const client =
|
|
154
|
-
|
|
155
|
-
transport: {
|
|
156
|
-
send: (data: string) => {
|
|
157
|
-
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
158
|
-
sent.push({ event: env.event, payload: env.payload });
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
traceIdFactory: () => "trace-2",
|
|
162
|
-
},
|
|
163
|
-
typing: vi.fn(),
|
|
164
|
-
} as unknown as ClawlingChatClient;
|
|
746
|
+
const client = mockClient(sent);
|
|
747
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
165
748
|
|
|
166
749
|
createOpenclawClawlingReplyDispatcher({
|
|
167
750
|
cfg: {} as never,
|
|
@@ -196,6 +779,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
196
779
|
senderNickName: "User 1",
|
|
197
780
|
bodyText: "hello",
|
|
198
781
|
},
|
|
782
|
+
store: store as never,
|
|
199
783
|
log: { info: vi.fn(), error: vi.fn() },
|
|
200
784
|
});
|
|
201
785
|
|
|
@@ -212,20 +796,15 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
212
796
|
expect(sent.find((entry) => entry.event === "message.done")).toBeUndefined();
|
|
213
797
|
});
|
|
214
798
|
|
|
215
|
-
it("
|
|
799
|
+
it("does not send static error text when non-streaming reply execution fails", async () => {
|
|
216
800
|
let hooks:
|
|
217
801
|
| {
|
|
218
802
|
onError?: (error: unknown, info: { kind: string }) => void;
|
|
219
803
|
}
|
|
220
804
|
| undefined;
|
|
221
|
-
const client =
|
|
222
|
-
sendMessage: vi.fn().mockResolvedValue({
|
|
223
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
224
|
-
}),
|
|
225
|
-
replyMessage: vi.fn(),
|
|
226
|
-
typing: vi.fn(),
|
|
227
|
-
} as unknown as ClawlingChatClient;
|
|
805
|
+
const client = mockClient();
|
|
228
806
|
|
|
807
|
+
const logError = vi.fn();
|
|
229
808
|
createOpenclawClawlingReplyDispatcher({
|
|
230
809
|
cfg: {} as never,
|
|
231
810
|
runtime: {
|
|
@@ -253,35 +832,27 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
253
832
|
} as never,
|
|
254
833
|
client,
|
|
255
834
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
256
|
-
log: { info: vi.fn(), error:
|
|
835
|
+
log: { info: vi.fn(), error: logError },
|
|
257
836
|
});
|
|
258
837
|
|
|
259
838
|
hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
|
|
260
839
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
261
840
|
|
|
262
|
-
expect(client.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
body: { fragments: [{ kind: "text", text: "Error: boom" }] },
|
|
266
|
-
}),
|
|
841
|
+
expect(client.sent).toHaveLength(0);
|
|
842
|
+
expect(logError).toHaveBeenCalledWith(
|
|
843
|
+
expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
|
|
267
844
|
);
|
|
268
|
-
expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
|
|
269
|
-
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
270
845
|
});
|
|
271
846
|
|
|
272
|
-
it("
|
|
847
|
+
it("does not attempt fallback static sends after non-streaming reply failures", async () => {
|
|
273
848
|
let hooks:
|
|
274
849
|
| {
|
|
275
850
|
onError?: (error: unknown, info: { kind: string }) => void;
|
|
276
851
|
}
|
|
277
852
|
| undefined;
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}),
|
|
282
|
-
replyMessage: vi.fn(),
|
|
283
|
-
typing: vi.fn(),
|
|
284
|
-
} as unknown as ClawlingChatClient;
|
|
853
|
+
const logError = vi.fn();
|
|
854
|
+
const client = mockClient();
|
|
855
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
285
856
|
|
|
286
857
|
createOpenclawClawlingReplyDispatcher({
|
|
287
858
|
cfg: {} as never,
|
|
@@ -310,7 +881,57 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
310
881
|
} as never,
|
|
311
882
|
client,
|
|
312
883
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
313
|
-
log: { info: vi.fn(), error:
|
|
884
|
+
log: { info: vi.fn(), error: logError },
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
hooks?.onError?.(new Error("final delivery failed"), { kind: "final" });
|
|
888
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
889
|
+
|
|
890
|
+
expect(client.sent).toHaveLength(0);
|
|
891
|
+
expect(logError).toHaveBeenCalledWith(
|
|
892
|
+
expect.stringContaining(
|
|
893
|
+
"openclaw-clawchat final reply failed: Error: final delivery failed",
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("strips delivery retry wrapper text before logging non-streaming errors", async () => {
|
|
899
|
+
let hooks:
|
|
900
|
+
| {
|
|
901
|
+
onError?: (error: unknown, info: { kind: string }) => void;
|
|
902
|
+
}
|
|
903
|
+
| undefined;
|
|
904
|
+
const client = mockClient();
|
|
905
|
+
|
|
906
|
+
const logError = vi.fn();
|
|
907
|
+
createOpenclawClawlingReplyDispatcher({
|
|
908
|
+
cfg: {} as never,
|
|
909
|
+
runtime: {
|
|
910
|
+
channel: {
|
|
911
|
+
reply: {
|
|
912
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
913
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
914
|
+
hooks = options;
|
|
915
|
+
return {
|
|
916
|
+
dispatcher: {},
|
|
917
|
+
replyOptions: {},
|
|
918
|
+
markDispatchIdle: vi.fn(),
|
|
919
|
+
};
|
|
920
|
+
}),
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
} as never,
|
|
924
|
+
account: {
|
|
925
|
+
accountId: "default",
|
|
926
|
+
userId: "agent-1",
|
|
927
|
+
replyMode: "static",
|
|
928
|
+
forwardThinking: true,
|
|
929
|
+
forwardToolCalls: false,
|
|
930
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
931
|
+
} as never,
|
|
932
|
+
client,
|
|
933
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
934
|
+
log: { info: vi.fn(), error: logError },
|
|
314
935
|
});
|
|
315
936
|
|
|
316
937
|
hooks?.onError?.(
|
|
@@ -319,13 +940,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
319
940
|
);
|
|
320
941
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
321
942
|
|
|
322
|
-
expect(client.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
body: { fragments: [{ kind: "text", text: "Error: boom" }] },
|
|
326
|
-
}),
|
|
943
|
+
expect(client.sent).toHaveLength(0);
|
|
944
|
+
expect(logError).toHaveBeenCalledWith(
|
|
945
|
+
expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
|
|
327
946
|
);
|
|
328
|
-
expect(
|
|
947
|
+
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining("Retry failed"));
|
|
329
948
|
});
|
|
330
949
|
|
|
331
950
|
it("emits approval rich fragments with fallback_text when rich interactions are enabled", async () => {
|
|
@@ -344,14 +963,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
344
963
|
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
345
964
|
}
|
|
346
965
|
| undefined;
|
|
347
|
-
const client =
|
|
348
|
-
|
|
349
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
350
|
-
trace_id: "trace-rich",
|
|
351
|
-
}),
|
|
352
|
-
replyMessage: vi.fn(),
|
|
353
|
-
typing: vi.fn(),
|
|
354
|
-
} as unknown as ClawlingChatClient;
|
|
966
|
+
const client = mockClient();
|
|
967
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
355
968
|
|
|
356
969
|
createOpenclawClawlingReplyDispatcher({
|
|
357
970
|
cfg: {} as never,
|
|
@@ -374,9 +987,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
374
987
|
forwardToolCalls: false,
|
|
375
988
|
richInteractions: true,
|
|
376
989
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
990
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
377
991
|
} as never,
|
|
378
992
|
client,
|
|
379
993
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
994
|
+
store: store as never,
|
|
380
995
|
log: { info: vi.fn(), error: vi.fn() },
|
|
381
996
|
});
|
|
382
997
|
|
|
@@ -401,8 +1016,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
401
1016
|
{ kind: "final" },
|
|
402
1017
|
);
|
|
403
1018
|
|
|
404
|
-
expect(client.
|
|
405
|
-
|
|
1019
|
+
expect(client.sent[0]?.payload).toMatchObject({
|
|
1020
|
+
message: {
|
|
406
1021
|
body: {
|
|
407
1022
|
fragments: [
|
|
408
1023
|
{
|
|
@@ -417,8 +1032,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
417
1032
|
},
|
|
418
1033
|
],
|
|
419
1034
|
},
|
|
420
|
-
}
|
|
421
|
-
);
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
422
1037
|
});
|
|
423
1038
|
|
|
424
1039
|
it("sends plain fallback text for presentations when rich interactions are disabled", async () => {
|
|
@@ -433,14 +1048,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
433
1048
|
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
434
1049
|
}
|
|
435
1050
|
| undefined;
|
|
436
|
-
const client =
|
|
437
|
-
|
|
438
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
439
|
-
trace_id: "trace-fallback",
|
|
440
|
-
}),
|
|
441
|
-
replyMessage: vi.fn(),
|
|
442
|
-
typing: vi.fn(),
|
|
443
|
-
} as unknown as ClawlingChatClient;
|
|
1051
|
+
const client = mockClient();
|
|
1052
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
444
1053
|
|
|
445
1054
|
createOpenclawClawlingReplyDispatcher({
|
|
446
1055
|
cfg: {} as never,
|
|
@@ -463,9 +1072,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
463
1072
|
forwardToolCalls: false,
|
|
464
1073
|
richInteractions: false,
|
|
465
1074
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1075
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
466
1076
|
} as never,
|
|
467
1077
|
client,
|
|
468
1078
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
1079
|
+
store: store as never,
|
|
469
1080
|
log: { info: vi.fn(), error: vi.fn() },
|
|
470
1081
|
});
|
|
471
1082
|
|
|
@@ -480,13 +1091,115 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
480
1091
|
{ kind: "final" },
|
|
481
1092
|
);
|
|
482
1093
|
|
|
483
|
-
expect(client.
|
|
484
|
-
|
|
1094
|
+
expect(client.sent[0]?.payload).toMatchObject({
|
|
1095
|
+
message: {
|
|
485
1096
|
body: {
|
|
486
1097
|
fragments: [{ kind: "text", text: expect.stringContaining("Delete /tmp/example.txt?") }],
|
|
487
1098
|
},
|
|
488
|
-
}
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("prefers mediaUrls over legacy mediaUrl so one image is not sent twice", async () => {
|
|
1104
|
+
let hooks:
|
|
1105
|
+
| {
|
|
1106
|
+
deliver?: (payload: {
|
|
1107
|
+
text?: string;
|
|
1108
|
+
mediaUrl?: string;
|
|
1109
|
+
mediaUrls?: string[];
|
|
1110
|
+
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
1111
|
+
}
|
|
1112
|
+
| undefined;
|
|
1113
|
+
const loadWebMedia = vi.fn(async (url: string) => ({
|
|
1114
|
+
buffer: Buffer.from(`bytes:${url}`),
|
|
1115
|
+
contentType: "image/png",
|
|
1116
|
+
fileName: "image.png",
|
|
1117
|
+
}));
|
|
1118
|
+
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
1119
|
+
new Response(
|
|
1120
|
+
JSON.stringify({
|
|
1121
|
+
code: 0,
|
|
1122
|
+
msg: "ok",
|
|
1123
|
+
data: {
|
|
1124
|
+
kind: "image",
|
|
1125
|
+
url: "https://cdn/uploaded.png",
|
|
1126
|
+
name: "uploaded.png",
|
|
1127
|
+
size: 12,
|
|
1128
|
+
mime: "image/png",
|
|
1129
|
+
},
|
|
1130
|
+
}),
|
|
1131
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1132
|
+
),
|
|
489
1133
|
);
|
|
1134
|
+
const client = mockClient();
|
|
1135
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
createOpenclawClawlingReplyDispatcher({
|
|
1139
|
+
cfg: {} as never,
|
|
1140
|
+
runtime: {
|
|
1141
|
+
media: { loadWebMedia },
|
|
1142
|
+
channel: {
|
|
1143
|
+
reply: {
|
|
1144
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
1145
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
1146
|
+
hooks = options;
|
|
1147
|
+
return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
|
|
1148
|
+
}),
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
} as never,
|
|
1152
|
+
account: {
|
|
1153
|
+
accountId: "default",
|
|
1154
|
+
baseUrl: "https://api.example.com",
|
|
1155
|
+
token: "tk",
|
|
1156
|
+
userId: "agent-1",
|
|
1157
|
+
replyMode: "static",
|
|
1158
|
+
forwardThinking: true,
|
|
1159
|
+
forwardToolCalls: false,
|
|
1160
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1161
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
1162
|
+
} as never,
|
|
1163
|
+
client,
|
|
1164
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
1165
|
+
store: store as never,
|
|
1166
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
await hooks?.deliver?.(
|
|
1170
|
+
{
|
|
1171
|
+
text: "look",
|
|
1172
|
+
mediaUrl: "https://cdn/legacy.png",
|
|
1173
|
+
mediaUrls: ["https://cdn/image.png"],
|
|
1174
|
+
},
|
|
1175
|
+
{ kind: "final" },
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
expect(loadWebMedia).toHaveBeenCalledTimes(1);
|
|
1179
|
+
expect(loadWebMedia).toHaveBeenCalledWith("https://cdn/image.png", expect.any(Object));
|
|
1180
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1181
|
+
expect(client.sent[0]).toMatchObject({
|
|
1182
|
+
chat_id: "chat-1",
|
|
1183
|
+
payload: {
|
|
1184
|
+
message: {
|
|
1185
|
+
body: {
|
|
1186
|
+
fragments: [
|
|
1187
|
+
{ kind: "text", text: "look" },
|
|
1188
|
+
{
|
|
1189
|
+
kind: "image",
|
|
1190
|
+
url: "https://cdn/uploaded.png",
|
|
1191
|
+
mime: "image/png",
|
|
1192
|
+
size: 12,
|
|
1193
|
+
name: "uploaded.png",
|
|
1194
|
+
},
|
|
1195
|
+
],
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
},
|
|
1199
|
+
});
|
|
1200
|
+
} finally {
|
|
1201
|
+
fetchMock.mockRestore();
|
|
1202
|
+
}
|
|
490
1203
|
});
|
|
491
1204
|
|
|
492
1205
|
it("includes rich interaction fragments in the consolidated streaming final reply", async () => {
|
|
@@ -507,18 +1220,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
507
1220
|
}
|
|
508
1221
|
| undefined;
|
|
509
1222
|
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
510
|
-
const client =
|
|
511
|
-
|
|
512
|
-
transport: {
|
|
513
|
-
send: (data: string) => {
|
|
514
|
-
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
515
|
-
sent.push({ event: env.event, payload: env.payload });
|
|
516
|
-
},
|
|
517
|
-
},
|
|
518
|
-
traceIdFactory: () => "trace-stream-rich",
|
|
519
|
-
},
|
|
520
|
-
typing: vi.fn(),
|
|
521
|
-
} as unknown as ClawlingChatClient;
|
|
1223
|
+
const client = mockClient(sent);
|
|
1224
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
522
1225
|
|
|
523
1226
|
createOpenclawClawlingReplyDispatcher({
|
|
524
1227
|
cfg: {} as never,
|
|
@@ -551,6 +1254,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
551
1254
|
senderNickName: "User 1",
|
|
552
1255
|
bodyText: "hello",
|
|
553
1256
|
},
|
|
1257
|
+
store: store as never,
|
|
554
1258
|
log: { info: vi.fn(), error: vi.fn() },
|
|
555
1259
|
});
|
|
556
1260
|
|
|
@@ -601,14 +1305,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
601
1305
|
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
602
1306
|
}
|
|
603
1307
|
| undefined;
|
|
604
|
-
const client =
|
|
605
|
-
|
|
606
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
607
|
-
trace_id: "trace-block-rich",
|
|
608
|
-
}),
|
|
609
|
-
replyMessage: vi.fn(),
|
|
610
|
-
typing: vi.fn(),
|
|
611
|
-
} as unknown as ClawlingChatClient;
|
|
1308
|
+
const client = mockClient();
|
|
1309
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
612
1310
|
|
|
613
1311
|
createOpenclawClawlingReplyDispatcher({
|
|
614
1312
|
cfg: {} as never,
|
|
@@ -631,9 +1329,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
631
1329
|
forwardToolCalls: false,
|
|
632
1330
|
richInteractions: true,
|
|
633
1331
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1332
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
634
1333
|
} as never,
|
|
635
1334
|
client,
|
|
636
1335
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
1336
|
+
store: store as never,
|
|
637
1337
|
log: { info: vi.fn(), error: vi.fn() },
|
|
638
1338
|
});
|
|
639
1339
|
|
|
@@ -650,8 +1350,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
650
1350
|
{ kind: "block" },
|
|
651
1351
|
);
|
|
652
1352
|
|
|
653
|
-
expect(client.
|
|
654
|
-
|
|
1353
|
+
expect(client.sent[0]?.payload).toMatchObject({
|
|
1354
|
+
message: {
|
|
655
1355
|
body: {
|
|
656
1356
|
fragments: [
|
|
657
1357
|
{
|
|
@@ -663,7 +1363,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
663
1363
|
},
|
|
664
1364
|
],
|
|
665
1365
|
},
|
|
666
|
-
}
|
|
667
|
-
);
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
668
1368
|
});
|
|
669
1369
|
});
|