@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -17
- package/dist/index.js +3 -1
- package/dist/src/api-client.js +71 -12
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +5 -5
- package/dist/src/channel.setup.js +4 -17
- package/dist/src/clawchat-memory.js +290 -0
- package/dist/src/clawchat-metadata.js +235 -0
- package/dist/src/client.js +31 -93
- package/dist/src/commands.js +3 -3
- package/dist/src/config.js +58 -3
- package/dist/src/group-message-coalescer.js +107 -0
- package/dist/src/inbound.js +24 -28
- package/dist/src/login.runtime.js +82 -19
- package/dist/src/media-runtime.js +2 -3
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +281 -56
- package/dist/src/plugin-prompts.js +76 -0
- package/dist/src/profile-prompt.js +150 -0
- package/dist/src/profile-sync.js +169 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -2
- package/dist/src/reply-dispatcher.js +143 -40
- package/dist/src/runtime.js +813 -109
- package/dist/src/storage.js +636 -0
- package/dist/src/tools-schema.js +70 -10
- package/dist/src/tools.js +600 -112
- package/dist/src/ws-alignment.js +8 -0
- package/dist/src/ws-client.js +588 -0
- package/index.ts +6 -1
- package/openclaw.plugin.json +44 -4
- package/package.json +4 -3
- package/prompts/platform.md +7 -0
- package/skills/clawchat/SKILL.md +90 -0
- package/src/api-client.test.ts +360 -15
- package/src/api-client.ts +127 -25
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +71 -4
- package/src/buffered-stream.test.ts +1 -1
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +270 -60
- package/src/channel.setup.ts +9 -18
- package/src/channel.test.ts +33 -25
- package/src/channel.ts +5 -7
- package/src/clawchat-memory.test.ts +372 -0
- package/src/clawchat-memory.ts +363 -0
- package/src/clawchat-metadata.test.ts +350 -0
- package/src/clawchat-metadata.ts +352 -0
- package/src/client.test.ts +57 -48
- package/src/client.ts +37 -129
- package/src/commands.test.ts +2 -2
- package/src/commands.ts +3 -3
- package/src/config.test.ts +169 -4
- package/src/config.ts +86 -6
- package/src/group-message-coalescer.test.ts +223 -0
- package/src/group-message-coalescer.ts +154 -0
- package/src/inbound.test.ts +106 -19
- package/src/inbound.ts +31 -35
- package/src/login.runtime.test.ts +294 -11
- package/src/login.runtime.ts +90 -21
- package/src/manifest.test.ts +86 -14
- package/src/media-runtime.test.ts +31 -2
- package/src/media-runtime.ts +7 -10
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +811 -95
- package/src/outbound.ts +332 -65
- package/src/plugin-entry.test.ts +3 -1
- package/src/plugin-prompts.test.ts +78 -0
- package/src/plugin-prompts.ts +92 -0
- package/src/profile-prompt.test.ts +435 -0
- package/src/profile-prompt.ts +208 -0
- package/src/profile-sync.test.ts +611 -0
- package/src/profile-sync.ts +268 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.ts +2 -2
- package/src/reply-dispatcher.test.ts +720 -135
- package/src/reply-dispatcher.ts +174 -42
- package/src/runtime.test.ts +3884 -337
- package/src/runtime.ts +956 -128
- package/src/storage.test.ts +692 -0
- package/src/storage.ts +989 -0
- package/src/streaming.test.ts +1 -1
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +115 -13
- package/src/tools.test.ts +501 -10
- package/src/tools.ts +739 -133
- package/src/ws-alignment.ts +9 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
|
@@ -1,8 +1,656 @@
|
|
|
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", () => {
|
|
173
|
+
it("forces OpenClaw block streaming delivery for ClawChat stream mode", () => {
|
|
174
|
+
let hooks: Record<string, unknown> = {};
|
|
175
|
+
const sent: SentFrame[] = [];
|
|
176
|
+
const client = mockClient(sent);
|
|
177
|
+
|
|
178
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
179
|
+
cfg: {} as never,
|
|
180
|
+
runtime: runtimeWithHooks((next) => {
|
|
181
|
+
hooks = next;
|
|
182
|
+
}),
|
|
183
|
+
account: replyAccount({ replyMode: "stream" }),
|
|
184
|
+
client,
|
|
185
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
186
|
+
store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
|
|
187
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(hooks).toBeTruthy();
|
|
191
|
+
expect((created.replyOptions as Record<string, unknown>).sourceReplyDeliveryMode).toBe("automatic");
|
|
192
|
+
expect((created.replyOptions as Record<string, unknown>).disableBlockStreaming).toBe(false);
|
|
193
|
+
expect(typeof created.replyOptions.onPartialReply).toBe("function");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not force OpenClaw block streaming delivery for explicit static mode", () => {
|
|
197
|
+
const client = mockClient();
|
|
198
|
+
|
|
199
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
200
|
+
cfg: {} as never,
|
|
201
|
+
runtime: runtimeWithHooks(() => {}),
|
|
202
|
+
account: replyAccount({ replyMode: "static" }),
|
|
203
|
+
client,
|
|
204
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
205
|
+
store: { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() } as never,
|
|
206
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect((created.replyOptions as Record<string, unknown>).disableBlockStreaming).toBeUndefined();
|
|
210
|
+
expect(created.replyOptions.onPartialReply).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("claims static outbound before send and uses the claimed payload message_id", async () => {
|
|
214
|
+
const store = {
|
|
215
|
+
claimMessageOnce: vi.fn(() => true),
|
|
216
|
+
insertMessage: vi.fn(),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const result = await runStaticReplyWithStore(store);
|
|
220
|
+
const messageId = store.claimMessageOnce.mock.calls[0]![0].messageId;
|
|
221
|
+
|
|
222
|
+
expect(messageId).toEqual(expect.any(String));
|
|
223
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
224
|
+
direction: "outbound",
|
|
225
|
+
kind: "message",
|
|
226
|
+
messageId,
|
|
227
|
+
}));
|
|
228
|
+
expect(result.sent[0]?.payload.message_id).toBe(messageId);
|
|
229
|
+
expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not send static outbound when the storage claim is duplicate or unavailable", async () => {
|
|
233
|
+
const duplicateStore = { claimMessageOnce: vi.fn(() => false), insertMessage: vi.fn() };
|
|
234
|
+
const unavailableStore = { claimMessageOnce: vi.fn(() => null), insertMessage: vi.fn() };
|
|
235
|
+
|
|
236
|
+
await expect(runStaticReplyWithStore(duplicateStore)).resolves.toMatchObject({ queuedFinal: false });
|
|
237
|
+
await expect(runStaticReplyWithStore(unavailableStore)).resolves.toMatchObject({ queuedFinal: false });
|
|
238
|
+
expect(duplicateStore.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
239
|
+
direction: "outbound",
|
|
240
|
+
kind: "message",
|
|
241
|
+
}));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("claims static forwarded thinking before sending", async () => {
|
|
245
|
+
let hooks: Record<string, unknown> = {};
|
|
246
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
247
|
+
const sent: SentFrame[] = [];
|
|
248
|
+
const client = mockClient(sent);
|
|
249
|
+
|
|
250
|
+
createOpenclawClawlingReplyDispatcher({
|
|
251
|
+
cfg: {} as never,
|
|
252
|
+
runtime: runtimeWithHooks((next) => {
|
|
253
|
+
hooks = next;
|
|
254
|
+
}),
|
|
255
|
+
account: replyAccount({ replyMode: "static", forwardThinking: true }),
|
|
256
|
+
client,
|
|
257
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
258
|
+
store: store as never,
|
|
259
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
|
|
263
|
+
{ text: "thinking text", isReasoning: true },
|
|
264
|
+
);
|
|
265
|
+
const messageId = store.claimMessageOnce.mock.calls[0]?.[0].messageId;
|
|
266
|
+
|
|
267
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
268
|
+
kind: "message",
|
|
269
|
+
direction: "outbound",
|
|
270
|
+
eventType: "message.send",
|
|
271
|
+
messageId,
|
|
272
|
+
text: "thinking text",
|
|
273
|
+
}));
|
|
274
|
+
expect(sent[0]?.payload.message_id).toBe(messageId);
|
|
275
|
+
expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
276
|
+
kind: "thinking",
|
|
277
|
+
messageId,
|
|
278
|
+
text: "thinking text",
|
|
279
|
+
}));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("does not send static forwarded thinking when the storage claim is duplicate or unavailable", async () => {
|
|
283
|
+
for (const claimResult of [false, null] as const) {
|
|
284
|
+
let hooks: Record<string, unknown> = {};
|
|
285
|
+
const store = { claimMessageOnce: vi.fn(() => claimResult), insertMessage: vi.fn() };
|
|
286
|
+
const sent: SentFrame[] = [];
|
|
287
|
+
const client = mockClient(sent);
|
|
288
|
+
|
|
289
|
+
createOpenclawClawlingReplyDispatcher({
|
|
290
|
+
cfg: {} as never,
|
|
291
|
+
runtime: runtimeWithHooks((next) => {
|
|
292
|
+
hooks = next;
|
|
293
|
+
}),
|
|
294
|
+
account: replyAccount({ replyMode: "static", forwardThinking: true }),
|
|
295
|
+
client,
|
|
296
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
297
|
+
store: store as never,
|
|
298
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await (hooks.deliver as (payload: { text?: string; isReasoning?: boolean }) => Promise<void>)(
|
|
302
|
+
{ text: "thinking text", isReasoning: true },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
306
|
+
kind: "message",
|
|
307
|
+
direction: "outbound",
|
|
308
|
+
}));
|
|
309
|
+
expect(sent).toHaveLength(0);
|
|
310
|
+
expect(store.insertMessage).not.toHaveBeenCalled();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("claims a streaming outbound message once and updates the row on final reply", async () => {
|
|
315
|
+
const store = {
|
|
316
|
+
claimMessageOnce: vi.fn(() => true),
|
|
317
|
+
updateMessageByIdentity: vi.fn(),
|
|
318
|
+
insertMessage: vi.fn(),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const result = await runStreamingReplyWithStore(store, ["hello", "hello final"]);
|
|
322
|
+
|
|
323
|
+
expect(result.streamingMessageId).toEqual(expect.any(String));
|
|
324
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
326
|
+
direction: "outbound",
|
|
327
|
+
eventType: "message.created",
|
|
328
|
+
messageId: result.streamingMessageId,
|
|
329
|
+
}));
|
|
330
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
331
|
+
direction: "outbound",
|
|
332
|
+
eventType: "message.reply",
|
|
333
|
+
messageId: result.streamingMessageId,
|
|
334
|
+
text: "hello final",
|
|
335
|
+
}));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("queues the consolidated streaming final reply while disconnected and flushes it after reconnect", async () => {
|
|
339
|
+
let hooks: Record<string, unknown> = {};
|
|
340
|
+
const sent: SentFrame[] = [];
|
|
341
|
+
const client = mockClient(sent, { transportState: "closed", state: "reconnecting" });
|
|
342
|
+
const store = {
|
|
343
|
+
claimMessageOnce: vi.fn(() => true),
|
|
344
|
+
updateMessageByIdentity: vi.fn(),
|
|
345
|
+
insertMessage: vi.fn(),
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
createOpenclawClawlingReplyDispatcher({
|
|
349
|
+
cfg: {} as never,
|
|
350
|
+
runtime: runtimeWithHooks((next) => {
|
|
351
|
+
hooks = next;
|
|
352
|
+
}),
|
|
353
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
354
|
+
client,
|
|
355
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
356
|
+
inboundMessageId: "inbound-1",
|
|
357
|
+
inboundForFinalReply: {
|
|
358
|
+
chatId: "chat-1",
|
|
359
|
+
senderId: "user-1",
|
|
360
|
+
senderNickName: "User 1",
|
|
361
|
+
bodyText: "hello",
|
|
362
|
+
},
|
|
363
|
+
store: store as never,
|
|
364
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
368
|
+
{ text: "final answer" },
|
|
369
|
+
{ kind: "final" },
|
|
370
|
+
);
|
|
371
|
+
const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
372
|
+
expect(streamingMessageId).toEqual(expect.any(String));
|
|
373
|
+
|
|
374
|
+
const idle = (hooks.onIdle as () => Promise<void>)();
|
|
375
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
376
|
+
|
|
377
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
378
|
+
expect(sent.filter((entry) => entry.event === "message.reply")).toHaveLength(0);
|
|
379
|
+
|
|
380
|
+
client.setTransportState("open");
|
|
381
|
+
client.setState("connected");
|
|
382
|
+
flushAlignedOutboundQueue(client);
|
|
383
|
+
await idle;
|
|
384
|
+
|
|
385
|
+
const finalReply = sent.find((entry) => entry.event === "message.reply");
|
|
386
|
+
expect(finalReply?.payload.message_id).toBe(streamingMessageId);
|
|
387
|
+
expect(finalReply?.payload).toMatchObject({
|
|
388
|
+
message: {
|
|
389
|
+
body: { fragments: [{ kind: "text", text: "final answer" }] },
|
|
390
|
+
context: {
|
|
391
|
+
reply: {
|
|
392
|
+
reply_to_msg_id: "inbound-1",
|
|
393
|
+
reply_preview: {
|
|
394
|
+
id: "user-1",
|
|
395
|
+
nick_name: "User 1",
|
|
396
|
+
fragments: [{ kind: "text", text: "hello" }],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
403
|
+
eventType: "message.reply",
|
|
404
|
+
messageId: streamingMessageId,
|
|
405
|
+
text: "final answer",
|
|
406
|
+
}));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("requeues only the consolidated streaming final reply after disconnect before ack", async () => {
|
|
410
|
+
let hooks: Record<string, unknown> = {};
|
|
411
|
+
const sent: SentFrame[] = [];
|
|
412
|
+
const client = mockClient(sent, { autoAck: false });
|
|
413
|
+
const store = {
|
|
414
|
+
claimMessageOnce: vi.fn(() => true),
|
|
415
|
+
updateMessageByIdentity: vi.fn(),
|
|
416
|
+
insertMessage: vi.fn(),
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
createOpenclawClawlingReplyDispatcher({
|
|
420
|
+
cfg: {} as never,
|
|
421
|
+
runtime: runtimeWithHooks((next) => {
|
|
422
|
+
hooks = next;
|
|
423
|
+
}),
|
|
424
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
425
|
+
client,
|
|
426
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
427
|
+
inboundMessageId: "inbound-1",
|
|
428
|
+
inboundForFinalReply: {
|
|
429
|
+
chatId: "chat-1",
|
|
430
|
+
senderId: "user-1",
|
|
431
|
+
senderNickName: "User 1",
|
|
432
|
+
bodyText: "hello",
|
|
433
|
+
},
|
|
434
|
+
store,
|
|
435
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
439
|
+
{ text: "final answer" },
|
|
440
|
+
{ kind: "final" },
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const idle = (hooks.onIdle as () => Promise<void>)();
|
|
444
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
445
|
+
|
|
446
|
+
const streamingMessageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
447
|
+
const firstFinalReply = sent.find((entry) => entry.event === "message.reply");
|
|
448
|
+
expect(streamingMessageId).toEqual(expect.any(String));
|
|
449
|
+
expect(firstFinalReply?.payload.message_id).toBe(streamingMessageId);
|
|
450
|
+
expect(firstFinalReply?.trace_id).toEqual(expect.any(String));
|
|
451
|
+
|
|
452
|
+
const lifecycleFramesBeforeReconnect = sent.filter((entry) =>
|
|
453
|
+
entry.event === "message.created" || entry.event === "message.add" || entry.event === "message.done"
|
|
454
|
+
);
|
|
455
|
+
const lifecycleCountsBeforeReconnect = {
|
|
456
|
+
created: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.created").length,
|
|
457
|
+
add: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.add").length,
|
|
458
|
+
done: lifecycleFramesBeforeReconnect.filter((entry) => entry.event === "message.done").length,
|
|
459
|
+
};
|
|
460
|
+
expect(lifecycleCountsBeforeReconnect.created).toBe(1);
|
|
461
|
+
expect(lifecycleCountsBeforeReconnect.add).toBeGreaterThanOrEqual(1);
|
|
462
|
+
expect(lifecycleCountsBeforeReconnect.done).toBe(1);
|
|
463
|
+
expect(lifecycleFramesBeforeReconnect.every((entry) => entry.payload.message_id === streamingMessageId)).toBe(true);
|
|
464
|
+
|
|
465
|
+
client.setTransportState("closed");
|
|
466
|
+
client.emit("close", { code: 1006, reason: "network lost" });
|
|
467
|
+
expect(getAlignedOutboundQueueSize(client)).toBe(1);
|
|
468
|
+
|
|
469
|
+
client.setTransportState("open");
|
|
470
|
+
client.setState("connected");
|
|
471
|
+
flushAlignedOutboundQueue(client);
|
|
472
|
+
|
|
473
|
+
expect({
|
|
474
|
+
created: sent.filter((entry) => entry.event === "message.created").length,
|
|
475
|
+
add: sent.filter((entry) => entry.event === "message.add").length,
|
|
476
|
+
done: sent.filter((entry) => entry.event === "message.done").length,
|
|
477
|
+
}).toEqual(lifecycleCountsBeforeReconnect);
|
|
478
|
+
|
|
479
|
+
const finalReplies = sent.filter((entry) => entry.event === "message.reply");
|
|
480
|
+
expect(finalReplies).toHaveLength(2);
|
|
481
|
+
expect(finalReplies[1]?.trace_id).toBe(firstFinalReply?.trace_id);
|
|
482
|
+
expect(finalReplies[1]?.payload.message_id).toBe(streamingMessageId);
|
|
483
|
+
|
|
484
|
+
client.emit("raw", {
|
|
485
|
+
version: "2",
|
|
486
|
+
event: "message.ack",
|
|
487
|
+
trace_id: firstFinalReply?.trace_id,
|
|
488
|
+
emitted_at: Date.now(),
|
|
489
|
+
payload: { message_id: streamingMessageId, accepted_at: 5678 },
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await idle;
|
|
493
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
494
|
+
eventType: "message.reply",
|
|
495
|
+
messageId: streamingMessageId,
|
|
496
|
+
text: "final answer",
|
|
497
|
+
}));
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("keeps thinking rows separate from outbound message claims", async () => {
|
|
501
|
+
let hooks: Record<string, unknown> = {};
|
|
502
|
+
const store = {
|
|
503
|
+
claimMessageOnce: vi.fn(() => true),
|
|
504
|
+
updateMessageByIdentity: vi.fn(),
|
|
505
|
+
insertMessage: vi.fn(),
|
|
506
|
+
};
|
|
507
|
+
const sent: SentFrame[] = [];
|
|
508
|
+
const client = mockClient(sent);
|
|
509
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
510
|
+
cfg: {} as never,
|
|
511
|
+
runtime: runtimeWithHooks((next) => {
|
|
512
|
+
hooks = next;
|
|
513
|
+
}),
|
|
514
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
515
|
+
client,
|
|
516
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
517
|
+
inboundMessageId: "inbound-1",
|
|
518
|
+
inboundForFinalReply: {
|
|
519
|
+
chatId: "chat-1",
|
|
520
|
+
senderId: "user-1",
|
|
521
|
+
senderNickName: "User 1",
|
|
522
|
+
bodyText: "hello",
|
|
523
|
+
},
|
|
524
|
+
store: store as never,
|
|
525
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
await created.replyOptions.onReasoningStream?.({ text: "thinking out loud" });
|
|
529
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
530
|
+
{ text: "final answer" },
|
|
531
|
+
{ kind: "final" },
|
|
532
|
+
);
|
|
533
|
+
await (hooks.onIdle as () => Promise<void>)();
|
|
534
|
+
const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
535
|
+
|
|
536
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
537
|
+
expect(store.insertMessage).toHaveBeenCalledTimes(1);
|
|
538
|
+
expect(store.insertMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
539
|
+
kind: "thinking",
|
|
540
|
+
messageId,
|
|
541
|
+
text: "thinking out loud",
|
|
542
|
+
}));
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("does not append a static message row after the pre-send claim", async () => {
|
|
546
|
+
let hooks: Record<string, unknown> = {};
|
|
547
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
548
|
+
const client = mockClient();
|
|
549
|
+
|
|
550
|
+
createOpenclawClawlingReplyDispatcher({
|
|
551
|
+
cfg: {} as never,
|
|
552
|
+
runtime: runtimeWithHooks((next) => {
|
|
553
|
+
hooks = next;
|
|
554
|
+
}),
|
|
555
|
+
account: replyAccount(),
|
|
556
|
+
client,
|
|
557
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
558
|
+
store,
|
|
559
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
563
|
+
{ text: "static reply" },
|
|
564
|
+
{ kind: "final" },
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
568
|
+
kind: "message",
|
|
569
|
+
direction: "outbound",
|
|
570
|
+
eventType: "message.send",
|
|
571
|
+
text: "static reply",
|
|
572
|
+
}));
|
|
573
|
+
expect(store.insertMessage).not.toHaveBeenCalledWith(expect.objectContaining({ kind: "message" }));
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("persists streaming final outbound message and thinking with the same final message id", async () => {
|
|
577
|
+
let hooks: Record<string, unknown> = {};
|
|
578
|
+
const store = {
|
|
579
|
+
claimMessageOnce: vi.fn(() => true),
|
|
580
|
+
updateMessageByIdentity: vi.fn(),
|
|
581
|
+
insertMessage: vi.fn(),
|
|
582
|
+
};
|
|
583
|
+
const sent: SentFrame[] = [];
|
|
584
|
+
const client = mockClient(sent);
|
|
585
|
+
|
|
586
|
+
const created = createOpenclawClawlingReplyDispatcher({
|
|
587
|
+
cfg: {} as never,
|
|
588
|
+
runtime: runtimeWithHooks((next) => {
|
|
589
|
+
hooks = next;
|
|
590
|
+
}),
|
|
591
|
+
account: replyAccount({ replyMode: "stream", forwardThinking: true }),
|
|
592
|
+
client,
|
|
593
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
594
|
+
inboundMessageId: "inbound-1",
|
|
595
|
+
inboundForFinalReply: {
|
|
596
|
+
chatId: "chat-1",
|
|
597
|
+
senderId: "user-1",
|
|
598
|
+
senderNickName: "User 1",
|
|
599
|
+
bodyText: "hello",
|
|
600
|
+
},
|
|
601
|
+
store,
|
|
602
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
await created.replyOptions.onReasoningStream?.({
|
|
606
|
+
text: "reasoning text",
|
|
607
|
+
});
|
|
608
|
+
await (hooks.deliver as (payload: { text?: string }, info: { kind: string }) => Promise<void>)(
|
|
609
|
+
{ text: "final answer" },
|
|
610
|
+
{ kind: "final" },
|
|
611
|
+
);
|
|
612
|
+
await (hooks.onIdle as () => Promise<void>)();
|
|
613
|
+
|
|
614
|
+
expect(sent.map((entry) => entry.event)).toEqual([
|
|
615
|
+
"message.created",
|
|
616
|
+
"message.add",
|
|
617
|
+
"message.add",
|
|
618
|
+
"message.done",
|
|
619
|
+
"message.reply",
|
|
620
|
+
]);
|
|
621
|
+
const messageId = sent.find((entry) => entry.event === "message.created")?.payload.message_id;
|
|
622
|
+
expect(store.claimMessageOnce).toHaveBeenCalledTimes(1);
|
|
623
|
+
expect(store.claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
624
|
+
platform: "openclaw",
|
|
625
|
+
accountId: "default",
|
|
626
|
+
kind: "message",
|
|
627
|
+
direction: "outbound",
|
|
628
|
+
eventType: "message.created",
|
|
629
|
+
chatId: "chat-1",
|
|
630
|
+
messageId,
|
|
631
|
+
}));
|
|
632
|
+
expect(store.updateMessageByIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
|
633
|
+
kind: "message",
|
|
634
|
+
direction: "outbound",
|
|
635
|
+
eventType: "message.reply",
|
|
636
|
+
messageId,
|
|
637
|
+
text: "final answer",
|
|
638
|
+
}));
|
|
639
|
+
expect(store.insertMessage).toHaveBeenCalledTimes(1);
|
|
640
|
+
const thinkingRow = store.insertMessage.mock.calls[0]![0];
|
|
641
|
+
expect(thinkingRow).toMatchObject({
|
|
642
|
+
platform: "openclaw",
|
|
643
|
+
accountId: "default",
|
|
644
|
+
kind: "thinking",
|
|
645
|
+
direction: "outbound",
|
|
646
|
+
eventType: "message.send",
|
|
647
|
+
chatId: "chat-1",
|
|
648
|
+
text: "reasoning text",
|
|
649
|
+
});
|
|
650
|
+
expect(thinkingRow.messageId).toBe(messageId);
|
|
651
|
+
expect(messageId).toEqual(expect.any(String));
|
|
652
|
+
});
|
|
653
|
+
|
|
6
654
|
it("uses the inbound sender id in consolidated streaming reply previews", async () => {
|
|
7
655
|
let hooks:
|
|
8
656
|
| {
|
|
@@ -11,18 +659,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
11
659
|
}
|
|
12
660
|
| undefined;
|
|
13
661
|
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;
|
|
662
|
+
const client = mockClient(sent);
|
|
663
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
26
664
|
|
|
27
665
|
createOpenclawClawlingReplyDispatcher({
|
|
28
666
|
cfg: {} as never,
|
|
@@ -58,6 +696,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
58
696
|
senderNickName: "User 1",
|
|
59
697
|
bodyText: "hello",
|
|
60
698
|
},
|
|
699
|
+
store: store as never,
|
|
61
700
|
log: { info: vi.fn(), error: vi.fn() },
|
|
62
701
|
});
|
|
63
702
|
|
|
@@ -88,18 +727,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
88
727
|
}
|
|
89
728
|
| undefined;
|
|
90
729
|
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;
|
|
730
|
+
const client = mockClient(sent);
|
|
731
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
103
732
|
|
|
104
733
|
createOpenclawClawlingReplyDispatcher({
|
|
105
734
|
cfg: {} as never,
|
|
@@ -154,18 +783,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
154
783
|
}
|
|
155
784
|
| undefined;
|
|
156
785
|
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
157
|
-
const client =
|
|
158
|
-
|
|
159
|
-
transport: {
|
|
160
|
-
send: (data: string) => {
|
|
161
|
-
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
162
|
-
sent.push({ event: env.event, payload: env.payload });
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
traceIdFactory: () => "trace-2",
|
|
166
|
-
},
|
|
167
|
-
typing: vi.fn(),
|
|
168
|
-
} as unknown as ClawlingChatClient;
|
|
786
|
+
const client = mockClient(sent);
|
|
787
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
169
788
|
|
|
170
789
|
createOpenclawClawlingReplyDispatcher({
|
|
171
790
|
cfg: {} as never,
|
|
@@ -200,6 +819,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
200
819
|
senderNickName: "User 1",
|
|
201
820
|
bodyText: "hello",
|
|
202
821
|
},
|
|
822
|
+
store: store as never,
|
|
203
823
|
log: { info: vi.fn(), error: vi.fn() },
|
|
204
824
|
});
|
|
205
825
|
|
|
@@ -222,13 +842,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
222
842
|
onError?: (error: unknown, info: { kind: string }) => void;
|
|
223
843
|
}
|
|
224
844
|
| undefined;
|
|
225
|
-
const client =
|
|
226
|
-
sendMessage: vi.fn().mockResolvedValue({
|
|
227
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
228
|
-
}),
|
|
229
|
-
replyMessage: vi.fn(),
|
|
230
|
-
typing: vi.fn(),
|
|
231
|
-
} as unknown as ClawlingChatClient;
|
|
845
|
+
const client = mockClient();
|
|
232
846
|
|
|
233
847
|
const logError = vi.fn();
|
|
234
848
|
createOpenclawClawlingReplyDispatcher({
|
|
@@ -264,8 +878,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
264
878
|
hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
|
|
265
879
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
266
880
|
|
|
267
|
-
expect(client.
|
|
268
|
-
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
881
|
+
expect(client.sent).toHaveLength(0);
|
|
269
882
|
expect(logError).toHaveBeenCalledWith(
|
|
270
883
|
expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
|
|
271
884
|
);
|
|
@@ -278,13 +891,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
278
891
|
}
|
|
279
892
|
| undefined;
|
|
280
893
|
const logError = vi.fn();
|
|
281
|
-
const client =
|
|
282
|
-
|
|
283
|
-
new Error("ack timeout after 15000ms for trace_id=trace-fallback"),
|
|
284
|
-
),
|
|
285
|
-
replyMessage: vi.fn(),
|
|
286
|
-
typing: vi.fn(),
|
|
287
|
-
} as unknown as ClawlingChatClient;
|
|
894
|
+
const client = mockClient();
|
|
895
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
288
896
|
|
|
289
897
|
createOpenclawClawlingReplyDispatcher({
|
|
290
898
|
cfg: {} as never,
|
|
@@ -319,7 +927,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
319
927
|
hooks?.onError?.(new Error("final delivery failed"), { kind: "final" });
|
|
320
928
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
321
929
|
|
|
322
|
-
expect(client.
|
|
930
|
+
expect(client.sent).toHaveLength(0);
|
|
323
931
|
expect(logError).toHaveBeenCalledWith(
|
|
324
932
|
expect.stringContaining(
|
|
325
933
|
"openclaw-clawchat final reply failed: Error: final delivery failed",
|
|
@@ -333,13 +941,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
333
941
|
onError?: (error: unknown, info: { kind: string }) => void;
|
|
334
942
|
}
|
|
335
943
|
| undefined;
|
|
336
|
-
const client =
|
|
337
|
-
sendMessage: vi.fn().mockResolvedValue({
|
|
338
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
339
|
-
}),
|
|
340
|
-
replyMessage: vi.fn(),
|
|
341
|
-
typing: vi.fn(),
|
|
342
|
-
} as unknown as ClawlingChatClient;
|
|
944
|
+
const client = mockClient();
|
|
343
945
|
|
|
344
946
|
const logError = vi.fn();
|
|
345
947
|
createOpenclawClawlingReplyDispatcher({
|
|
@@ -378,7 +980,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
378
980
|
);
|
|
379
981
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
380
982
|
|
|
381
|
-
expect(client.
|
|
983
|
+
expect(client.sent).toHaveLength(0);
|
|
382
984
|
expect(logError).toHaveBeenCalledWith(
|
|
383
985
|
expect.stringContaining("openclaw-clawchat dispatch reply failed: Error: boom"),
|
|
384
986
|
);
|
|
@@ -401,14 +1003,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
401
1003
|
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
402
1004
|
}
|
|
403
1005
|
| undefined;
|
|
404
|
-
const client =
|
|
405
|
-
|
|
406
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
407
|
-
trace_id: "trace-rich",
|
|
408
|
-
}),
|
|
409
|
-
replyMessage: vi.fn(),
|
|
410
|
-
typing: vi.fn(),
|
|
411
|
-
} as unknown as ClawlingChatClient;
|
|
1006
|
+
const client = mockClient();
|
|
1007
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
412
1008
|
|
|
413
1009
|
createOpenclawClawlingReplyDispatcher({
|
|
414
1010
|
cfg: {} as never,
|
|
@@ -431,9 +1027,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
431
1027
|
forwardToolCalls: false,
|
|
432
1028
|
richInteractions: true,
|
|
433
1029
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1030
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
434
1031
|
} as never,
|
|
435
1032
|
client,
|
|
436
1033
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
1034
|
+
store: store as never,
|
|
437
1035
|
log: { info: vi.fn(), error: vi.fn() },
|
|
438
1036
|
});
|
|
439
1037
|
|
|
@@ -458,8 +1056,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
458
1056
|
{ kind: "final" },
|
|
459
1057
|
);
|
|
460
1058
|
|
|
461
|
-
expect(client.
|
|
462
|
-
|
|
1059
|
+
expect(client.sent[0]?.payload).toMatchObject({
|
|
1060
|
+
message: {
|
|
463
1061
|
body: {
|
|
464
1062
|
fragments: [
|
|
465
1063
|
{
|
|
@@ -474,8 +1072,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
474
1072
|
},
|
|
475
1073
|
],
|
|
476
1074
|
},
|
|
477
|
-
}
|
|
478
|
-
);
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
479
1077
|
});
|
|
480
1078
|
|
|
481
1079
|
it("sends plain fallback text for presentations when rich interactions are disabled", async () => {
|
|
@@ -490,14 +1088,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
490
1088
|
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
491
1089
|
}
|
|
492
1090
|
| undefined;
|
|
493
|
-
const client =
|
|
494
|
-
|
|
495
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
496
|
-
trace_id: "trace-fallback",
|
|
497
|
-
}),
|
|
498
|
-
replyMessage: vi.fn(),
|
|
499
|
-
typing: vi.fn(),
|
|
500
|
-
} as unknown as ClawlingChatClient;
|
|
1091
|
+
const client = mockClient();
|
|
1092
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
501
1093
|
|
|
502
1094
|
createOpenclawClawlingReplyDispatcher({
|
|
503
1095
|
cfg: {} as never,
|
|
@@ -520,9 +1112,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
520
1112
|
forwardToolCalls: false,
|
|
521
1113
|
richInteractions: false,
|
|
522
1114
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1115
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
523
1116
|
} as never,
|
|
524
1117
|
client,
|
|
525
1118
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
1119
|
+
store: store as never,
|
|
526
1120
|
log: { info: vi.fn(), error: vi.fn() },
|
|
527
1121
|
});
|
|
528
1122
|
|
|
@@ -537,13 +1131,13 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
537
1131
|
{ kind: "final" },
|
|
538
1132
|
);
|
|
539
1133
|
|
|
540
|
-
expect(client.
|
|
541
|
-
|
|
1134
|
+
expect(client.sent[0]?.payload).toMatchObject({
|
|
1135
|
+
message: {
|
|
542
1136
|
body: {
|
|
543
1137
|
fragments: [{ kind: "text", text: expect.stringContaining("Delete /tmp/example.txt?") }],
|
|
544
1138
|
},
|
|
545
|
-
}
|
|
546
|
-
);
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
547
1141
|
});
|
|
548
1142
|
|
|
549
1143
|
it("prefers mediaUrls over legacy mediaUrl so one image is not sent twice", async () => {
|
|
@@ -566,19 +1160,19 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
566
1160
|
JSON.stringify({
|
|
567
1161
|
code: 0,
|
|
568
1162
|
msg: "ok",
|
|
569
|
-
data: {
|
|
1163
|
+
data: {
|
|
1164
|
+
kind: "image",
|
|
1165
|
+
url: "https://cdn/uploaded.png",
|
|
1166
|
+
name: "uploaded.png",
|
|
1167
|
+
size: 12,
|
|
1168
|
+
mime: "image/png",
|
|
1169
|
+
},
|
|
570
1170
|
}),
|
|
571
1171
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
572
1172
|
),
|
|
573
1173
|
);
|
|
574
|
-
const client =
|
|
575
|
-
|
|
576
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
577
|
-
trace_id: "trace-media",
|
|
578
|
-
}),
|
|
579
|
-
replyMessage: vi.fn(),
|
|
580
|
-
typing: vi.fn(),
|
|
581
|
-
} as unknown as ClawlingChatClient;
|
|
1174
|
+
const client = mockClient();
|
|
1175
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
582
1176
|
|
|
583
1177
|
try {
|
|
584
1178
|
createOpenclawClawlingReplyDispatcher({
|
|
@@ -604,9 +1198,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
604
1198
|
forwardThinking: true,
|
|
605
1199
|
forwardToolCalls: false,
|
|
606
1200
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1201
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
607
1202
|
} as never,
|
|
608
1203
|
client,
|
|
609
1204
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
1205
|
+
store: store as never,
|
|
610
1206
|
log: { info: vi.fn(), error: vi.fn() },
|
|
611
1207
|
});
|
|
612
1208
|
|
|
@@ -622,23 +1218,25 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
622
1218
|
expect(loadWebMedia).toHaveBeenCalledTimes(1);
|
|
623
1219
|
expect(loadWebMedia).toHaveBeenCalledWith("https://cdn/image.png", expect.any(Object));
|
|
624
1220
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
625
|
-
expect(client.
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1221
|
+
expect(client.sent[0]).toMatchObject({
|
|
1222
|
+
chat_id: "chat-1",
|
|
1223
|
+
payload: {
|
|
1224
|
+
message: {
|
|
1225
|
+
body: {
|
|
1226
|
+
fragments: [
|
|
1227
|
+
{ kind: "text", text: "look" },
|
|
1228
|
+
{
|
|
1229
|
+
kind: "image",
|
|
1230
|
+
url: "https://cdn/uploaded.png",
|
|
1231
|
+
mime: "image/png",
|
|
1232
|
+
size: 12,
|
|
1233
|
+
name: "uploaded.png",
|
|
1234
|
+
},
|
|
1235
|
+
],
|
|
1236
|
+
},
|
|
639
1237
|
},
|
|
640
|
-
}
|
|
641
|
-
);
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
642
1240
|
} finally {
|
|
643
1241
|
fetchMock.mockRestore();
|
|
644
1242
|
}
|
|
@@ -662,18 +1260,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
662
1260
|
}
|
|
663
1261
|
| undefined;
|
|
664
1262
|
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
665
|
-
const client =
|
|
666
|
-
|
|
667
|
-
transport: {
|
|
668
|
-
send: (data: string) => {
|
|
669
|
-
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
670
|
-
sent.push({ event: env.event, payload: env.payload });
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
traceIdFactory: () => "trace-stream-rich",
|
|
674
|
-
},
|
|
675
|
-
typing: vi.fn(),
|
|
676
|
-
} as unknown as ClawlingChatClient;
|
|
1263
|
+
const client = mockClient(sent);
|
|
1264
|
+
const store = { claimMessageOnce: vi.fn(() => true), updateMessageByIdentity: vi.fn(), insertMessage: vi.fn() };
|
|
677
1265
|
|
|
678
1266
|
createOpenclawClawlingReplyDispatcher({
|
|
679
1267
|
cfg: {} as never,
|
|
@@ -706,6 +1294,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
706
1294
|
senderNickName: "User 1",
|
|
707
1295
|
bodyText: "hello",
|
|
708
1296
|
},
|
|
1297
|
+
store: store as never,
|
|
709
1298
|
log: { info: vi.fn(), error: vi.fn() },
|
|
710
1299
|
});
|
|
711
1300
|
|
|
@@ -756,14 +1345,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
756
1345
|
}, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
757
1346
|
}
|
|
758
1347
|
| undefined;
|
|
759
|
-
const client =
|
|
760
|
-
|
|
761
|
-
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
762
|
-
trace_id: "trace-block-rich",
|
|
763
|
-
}),
|
|
764
|
-
replyMessage: vi.fn(),
|
|
765
|
-
typing: vi.fn(),
|
|
766
|
-
} as unknown as ClawlingChatClient;
|
|
1348
|
+
const client = mockClient();
|
|
1349
|
+
const store = { claimMessageOnce: vi.fn(() => true), insertMessage: vi.fn() };
|
|
767
1350
|
|
|
768
1351
|
createOpenclawClawlingReplyDispatcher({
|
|
769
1352
|
cfg: {} as never,
|
|
@@ -786,9 +1369,11 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
786
1369
|
forwardToolCalls: false,
|
|
787
1370
|
richInteractions: true,
|
|
788
1371
|
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
1372
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
789
1373
|
} as never,
|
|
790
1374
|
client,
|
|
791
1375
|
target: { chatId: "chat-1", chatType: "direct" },
|
|
1376
|
+
store: store as never,
|
|
792
1377
|
log: { info: vi.fn(), error: vi.fn() },
|
|
793
1378
|
});
|
|
794
1379
|
|
|
@@ -805,8 +1390,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
805
1390
|
{ kind: "block" },
|
|
806
1391
|
);
|
|
807
1392
|
|
|
808
|
-
expect(client.
|
|
809
|
-
|
|
1393
|
+
expect(client.sent[0]?.payload).toMatchObject({
|
|
1394
|
+
message: {
|
|
810
1395
|
body: {
|
|
811
1396
|
fragments: [
|
|
812
1397
|
{
|
|
@@ -818,7 +1403,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
818
1403
|
},
|
|
819
1404
|
],
|
|
820
1405
|
},
|
|
821
|
-
}
|
|
822
|
-
);
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
823
1408
|
});
|
|
824
1409
|
});
|