@newbase-clawchat/openclaw-clawchat 2026.4.22 → 2026.4.24
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/index.ts +21 -10
- package/package.json +5 -7
- package/src/channel.outbound.test.ts +87 -0
- package/src/outbound.ts +11 -9
- package/src/reply-dispatcher.test.ts +252 -0
- package/src/reply-dispatcher.ts +37 -3
- package/src/runtime.test.ts +150 -0
- package/src/runtime.ts +47 -13
package/index.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
1
|
+
// import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
3
|
import { openclawClawlingPlugin } from "./src/channel.ts";
|
|
3
4
|
import { setOpenclawClawlingRuntime } from "./src/runtime.ts";
|
|
4
5
|
import { registerOpenclawClawlingTools } from "./src/tools.ts";
|
|
6
|
+
import { openclawClawlingConfigSchema } from "./src/config.ts";
|
|
5
7
|
|
|
6
|
-
export { openclawClawlingPlugin } from "./src/channel.ts";
|
|
7
|
-
export { setOpenclawClawlingRuntime } from "./src/runtime.ts";
|
|
8
|
-
export { registerOpenclawClawlingTools } from "./src/tools.ts";
|
|
9
8
|
|
|
10
|
-
export default
|
|
9
|
+
export default {
|
|
11
10
|
id: "openclaw-clawchat",
|
|
12
11
|
name: "Clawling Chat",
|
|
13
12
|
description: "Clawling Chat Protocol v2 channel plugin (chat-sdk)",
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
configSchema: openclawClawlingConfigSchema,
|
|
14
|
+
register(api: OpenClawPluginApi) {
|
|
15
|
+
setOpenclawClawlingRuntime(api.runtime);
|
|
16
|
+
api.registerChannel({ plugin: openclawClawlingPlugin });
|
|
17
17
|
registerOpenclawClawlingTools(api);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// export default defineChannelPluginEntry({
|
|
22
|
+
// id: "openclaw-clawchat",
|
|
23
|
+
// name: "Clawling Chat",
|
|
24
|
+
// description: "Clawling Chat Protocol v2 channel plugin (chat-sdk)",
|
|
25
|
+
// plugin: openclawClawlingPlugin,
|
|
26
|
+
// setRuntime: setOpenclawClawlingRuntime,
|
|
27
|
+
// registerFull(api) {
|
|
28
|
+
// registerOpenclawClawlingTools(api);
|
|
29
|
+
// },
|
|
30
|
+
// });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newbase-clawchat/openclaw-clawchat",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.24",
|
|
4
4
|
"description": "OpenClaw ClawChat channel plugin",
|
|
5
5
|
"files": [
|
|
6
6
|
"index.ts",
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
14
|
"prepublishOnly": "npm run typecheck",
|
|
15
|
-
"release": "npm run prepublishOnly && npm publish"
|
|
16
|
-
"test-ui": "node ./tools/standalone-webchat-server.mjs"
|
|
15
|
+
"release": "npm run prepublishOnly && npm publish"
|
|
17
16
|
},
|
|
18
17
|
"dependencies": {
|
|
19
18
|
"@newbase-clawchat/sdk": "^0.1.0",
|
|
@@ -41,16 +40,15 @@
|
|
|
41
40
|
],
|
|
42
41
|
"channel": {
|
|
43
42
|
"id": "openclaw-clawchat",
|
|
44
|
-
"label": "
|
|
45
|
-
"selectionLabel": "
|
|
43
|
+
"label": "openclaw-clawchat",
|
|
44
|
+
"selectionLabel": "openclaw-clawchat",
|
|
46
45
|
"docsPath": "/channels/openclaw-clawchat",
|
|
47
46
|
"docsLabel": "openclaw-clawchat",
|
|
48
47
|
"blurb": "OpenClaw ClawChat channel plugin",
|
|
49
|
-
"order":
|
|
48
|
+
"order": 70
|
|
50
49
|
},
|
|
51
50
|
"install": {
|
|
52
51
|
"npmSpec": "@newbase-clawchat/openclaw-clawchat",
|
|
53
|
-
"localPath": "extensions/openclaw-clawchat",
|
|
54
52
|
"defaultChoice": "npm",
|
|
55
53
|
"minHostVersion": ">=2026.3.23"
|
|
56
54
|
}
|
|
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
const getClientMock = vi.hoisted(() => vi.fn());
|
|
4
4
|
const getRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const waitForClientMock = vi.hoisted(() => vi.fn());
|
|
5
6
|
const uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
|
|
6
7
|
const createApiClientMock = vi.hoisted(() => vi.fn());
|
|
7
8
|
const sendTextMock = vi.hoisted(() => vi.fn());
|
|
@@ -10,6 +11,7 @@ const sendMediaMock = vi.hoisted(() => vi.fn());
|
|
|
10
11
|
vi.mock("./runtime.ts", () => ({
|
|
11
12
|
getOpenclawClawlingClient: getClientMock,
|
|
12
13
|
getOpenclawClawlingRuntime: getRuntimeMock,
|
|
14
|
+
waitForOpenclawClawlingClient: waitForClientMock,
|
|
13
15
|
startOpenclawClawlingGateway: vi.fn(),
|
|
14
16
|
}));
|
|
15
17
|
|
|
@@ -31,12 +33,50 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
31
33
|
vi.resetModules();
|
|
32
34
|
getClientMock.mockReset();
|
|
33
35
|
getRuntimeMock.mockReset();
|
|
36
|
+
waitForClientMock.mockReset();
|
|
34
37
|
uploadOutboundMediaMock.mockReset();
|
|
35
38
|
createApiClientMock.mockReset();
|
|
36
39
|
sendTextMock.mockReset();
|
|
37
40
|
sendMediaMock.mockReset();
|
|
38
41
|
});
|
|
39
42
|
|
|
43
|
+
it("sendText waits for client activation when no active client exists yet", async () => {
|
|
44
|
+
const client = { sendMessage: vi.fn() };
|
|
45
|
+
getClientMock.mockReturnValue(undefined);
|
|
46
|
+
waitForClientMock.mockResolvedValue(client);
|
|
47
|
+
sendTextMock.mockResolvedValue({ messageId: "m-2", acceptedAt: 456 });
|
|
48
|
+
|
|
49
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
50
|
+
const result = await openclawClawlingOutbound.sendText!({
|
|
51
|
+
cfg: {
|
|
52
|
+
channels: {
|
|
53
|
+
"openclaw-clawchat": {
|
|
54
|
+
enabled: true,
|
|
55
|
+
websocketUrl: "ws://t",
|
|
56
|
+
baseUrl: "https://api.example.com",
|
|
57
|
+
token: "tk",
|
|
58
|
+
userId: "agent-1",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
} as never,
|
|
62
|
+
to: "cc:user-1",
|
|
63
|
+
text: "hello",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(waitForClientMock).toHaveBeenCalledWith("default");
|
|
67
|
+
expect(sendTextMock).toHaveBeenCalledWith({
|
|
68
|
+
client,
|
|
69
|
+
account: expect.objectContaining({ userId: "agent-1" }),
|
|
70
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
71
|
+
text: "hello",
|
|
72
|
+
});
|
|
73
|
+
expect(result).toEqual({
|
|
74
|
+
channel: "openclaw-clawchat",
|
|
75
|
+
to: "cc:user-1",
|
|
76
|
+
messageId: "m-2",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
40
80
|
it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
|
|
41
81
|
const client = { sendMessage: vi.fn() };
|
|
42
82
|
const runtime = { media: { loadWebMedia: vi.fn() } };
|
|
@@ -113,4 +153,51 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
113
153
|
}),
|
|
114
154
|
).rejects.toThrow(/requires mediaUrl/);
|
|
115
155
|
});
|
|
156
|
+
|
|
157
|
+
it("sendMedia waits for client activation when no active client exists yet", async () => {
|
|
158
|
+
const client = { sendMessage: vi.fn() };
|
|
159
|
+
const runtime = { media: { loadWebMedia: vi.fn() } };
|
|
160
|
+
const apiClient = { uploadMedia: vi.fn() };
|
|
161
|
+
getClientMock.mockReturnValue(undefined);
|
|
162
|
+
waitForClientMock.mockResolvedValue(client);
|
|
163
|
+
getRuntimeMock.mockReturnValue(runtime);
|
|
164
|
+
createApiClientMock.mockReturnValue(apiClient);
|
|
165
|
+
uploadOutboundMediaMock.mockResolvedValue([
|
|
166
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
167
|
+
]);
|
|
168
|
+
sendMediaMock.mockResolvedValue({ messageId: "m-3", acceptedAt: 789 });
|
|
169
|
+
|
|
170
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
171
|
+
const result = await openclawClawlingOutbound.sendMedia!({
|
|
172
|
+
cfg: {
|
|
173
|
+
channels: {
|
|
174
|
+
"openclaw-clawchat": {
|
|
175
|
+
enabled: true,
|
|
176
|
+
websocketUrl: "ws://t",
|
|
177
|
+
baseUrl: "https://api.example.com",
|
|
178
|
+
token: "tk",
|
|
179
|
+
userId: "agent-1",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
} as never,
|
|
183
|
+
to: "cc:group:room-1",
|
|
184
|
+
text: "caption",
|
|
185
|
+
mediaUrl: "/tmp/photo.png",
|
|
186
|
+
mediaLocalRoots: ["/tmp"],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(waitForClientMock).toHaveBeenCalledWith("default");
|
|
190
|
+
expect(sendMediaMock).toHaveBeenCalledWith({
|
|
191
|
+
client,
|
|
192
|
+
account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
|
|
193
|
+
to: { chatId: "room-1", chatType: "group" },
|
|
194
|
+
text: "caption",
|
|
195
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
|
|
196
|
+
});
|
|
197
|
+
expect(result).toEqual({
|
|
198
|
+
channel: "openclaw-clawchat",
|
|
199
|
+
to: "cc:group:room-1",
|
|
200
|
+
messageId: "m-3",
|
|
201
|
+
});
|
|
202
|
+
});
|
|
116
203
|
});
|
package/src/outbound.ts
CHANGED
|
@@ -9,7 +9,11 @@ import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
|
9
9
|
import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
10
10
|
import { textToFragments } from "./message-mapper.ts";
|
|
11
11
|
import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getOpenclawClawlingClient,
|
|
14
|
+
getOpenclawClawlingRuntime,
|
|
15
|
+
waitForOpenclawClawlingClient,
|
|
16
|
+
} from "./runtime.ts";
|
|
13
17
|
|
|
14
18
|
export interface OutboundTarget {
|
|
15
19
|
chatId: string;
|
|
@@ -198,10 +202,9 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
|
|
|
198
202
|
channel: CHANNEL_ID,
|
|
199
203
|
sendText: async ({ cfg, to, text }) => {
|
|
200
204
|
const account = resolveOpenclawClawlingAccount(cfg);
|
|
201
|
-
const client =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
+
const client =
|
|
206
|
+
getOpenclawClawlingClient(account.accountId) ??
|
|
207
|
+
(await waitForOpenclawClawlingClient(account.accountId));
|
|
205
208
|
const result = await sendOpenclawClawlingText({
|
|
206
209
|
client,
|
|
207
210
|
account,
|
|
@@ -215,10 +218,9 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
|
|
|
215
218
|
},
|
|
216
219
|
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
|
|
217
220
|
const account = resolveOpenclawClawlingAccount(cfg);
|
|
218
|
-
const client =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
221
|
+
const client =
|
|
222
|
+
getOpenclawClawlingClient(account.accountId) ??
|
|
223
|
+
(await waitForOpenclawClawlingClient(account.accountId));
|
|
222
224
|
if (!mediaUrl?.trim()) {
|
|
223
225
|
throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
|
|
224
226
|
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
|
|
4
|
+
|
|
5
|
+
describe("openclaw-clawchat reply-dispatcher", () => {
|
|
6
|
+
it("emits message.failed in stream mode even if execution errors before any stream chunk", async () => {
|
|
7
|
+
let hooks:
|
|
8
|
+
| {
|
|
9
|
+
deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
10
|
+
onIdle?: () => Promise<void>;
|
|
11
|
+
onError?: (error: unknown, info: { kind: string }) => void;
|
|
12
|
+
}
|
|
13
|
+
| undefined;
|
|
14
|
+
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
15
|
+
const client = {
|
|
16
|
+
opts: {
|
|
17
|
+
transport: {
|
|
18
|
+
send: (data: string) => {
|
|
19
|
+
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
20
|
+
sent.push({ event: env.event, payload: env.payload });
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
traceIdFactory: () => "trace-1",
|
|
24
|
+
},
|
|
25
|
+
typing: vi.fn(),
|
|
26
|
+
} as unknown as ClawlingChatClient;
|
|
27
|
+
|
|
28
|
+
createOpenclawClawlingReplyDispatcher({
|
|
29
|
+
cfg: {} as never,
|
|
30
|
+
runtime: {
|
|
31
|
+
channel: {
|
|
32
|
+
reply: {
|
|
33
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
34
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
35
|
+
hooks = options;
|
|
36
|
+
return {
|
|
37
|
+
dispatcher: {},
|
|
38
|
+
replyOptions: {},
|
|
39
|
+
markDispatchIdle: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
} as never,
|
|
45
|
+
account: {
|
|
46
|
+
accountId: "default",
|
|
47
|
+
userId: "agent-1",
|
|
48
|
+
replyMode: "stream",
|
|
49
|
+
forwardThinking: true,
|
|
50
|
+
forwardToolCalls: false,
|
|
51
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
52
|
+
} as never,
|
|
53
|
+
client,
|
|
54
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
55
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
|
|
59
|
+
|
|
60
|
+
expect(sent).toHaveLength(1);
|
|
61
|
+
expect(sent[0]!.event).toBe("message.failed");
|
|
62
|
+
expect(sent[0]!.payload.reason).toBe("Error: boom");
|
|
63
|
+
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
64
|
+
["chat-1", false, "direct"],
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not emit consolidated final after a streaming failure", async () => {
|
|
69
|
+
let hooks:
|
|
70
|
+
| {
|
|
71
|
+
deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
|
72
|
+
onIdle?: () => Promise<void>;
|
|
73
|
+
onError?: (error: unknown, info: { kind: string }) => void;
|
|
74
|
+
}
|
|
75
|
+
| undefined;
|
|
76
|
+
const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
77
|
+
const client = {
|
|
78
|
+
opts: {
|
|
79
|
+
transport: {
|
|
80
|
+
send: (data: string) => {
|
|
81
|
+
const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
|
|
82
|
+
sent.push({ event: env.event, payload: env.payload });
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
traceIdFactory: () => "trace-2",
|
|
86
|
+
},
|
|
87
|
+
typing: vi.fn(),
|
|
88
|
+
} as unknown as ClawlingChatClient;
|
|
89
|
+
|
|
90
|
+
createOpenclawClawlingReplyDispatcher({
|
|
91
|
+
cfg: {} as never,
|
|
92
|
+
runtime: {
|
|
93
|
+
channel: {
|
|
94
|
+
reply: {
|
|
95
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
96
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
97
|
+
hooks = options;
|
|
98
|
+
return {
|
|
99
|
+
dispatcher: {},
|
|
100
|
+
replyOptions: {},
|
|
101
|
+
markDispatchIdle: vi.fn(),
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
} as never,
|
|
107
|
+
account: {
|
|
108
|
+
accountId: "default",
|
|
109
|
+
userId: "agent-1",
|
|
110
|
+
replyMode: "stream",
|
|
111
|
+
forwardThinking: true,
|
|
112
|
+
forwardToolCalls: false,
|
|
113
|
+
stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
|
|
114
|
+
} as never,
|
|
115
|
+
client,
|
|
116
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
117
|
+
inboundMessageId: "inbound-1",
|
|
118
|
+
inboundForFinalReply: {
|
|
119
|
+
senderId: "user-1",
|
|
120
|
+
senderNickName: "User 1",
|
|
121
|
+
bodyText: "hello",
|
|
122
|
+
},
|
|
123
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await hooks?.deliver?.({ text: "partial reply" }, { kind: "block" });
|
|
127
|
+
hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
|
|
128
|
+
await hooks?.onIdle?.();
|
|
129
|
+
|
|
130
|
+
expect(sent.map((entry) => entry.event)).toEqual([
|
|
131
|
+
"message.created",
|
|
132
|
+
"message.add",
|
|
133
|
+
"message.failed",
|
|
134
|
+
]);
|
|
135
|
+
expect(sent.find((entry) => entry.event === "message.reply")).toBeUndefined();
|
|
136
|
+
expect(sent.find((entry) => entry.event === "message.done")).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("sends static error text when non-streaming reply execution fails", async () => {
|
|
140
|
+
let hooks:
|
|
141
|
+
| {
|
|
142
|
+
onError?: (error: unknown, info: { kind: string }) => void;
|
|
143
|
+
}
|
|
144
|
+
| undefined;
|
|
145
|
+
const client = {
|
|
146
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
147
|
+
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
148
|
+
}),
|
|
149
|
+
replyMessage: vi.fn(),
|
|
150
|
+
typing: vi.fn(),
|
|
151
|
+
} as unknown as ClawlingChatClient;
|
|
152
|
+
|
|
153
|
+
createOpenclawClawlingReplyDispatcher({
|
|
154
|
+
cfg: {} as never,
|
|
155
|
+
runtime: {
|
|
156
|
+
channel: {
|
|
157
|
+
reply: {
|
|
158
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
159
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
160
|
+
hooks = options;
|
|
161
|
+
return {
|
|
162
|
+
dispatcher: {},
|
|
163
|
+
replyOptions: {},
|
|
164
|
+
markDispatchIdle: vi.fn(),
|
|
165
|
+
};
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
} as never,
|
|
170
|
+
account: {
|
|
171
|
+
accountId: "default",
|
|
172
|
+
userId: "agent-1",
|
|
173
|
+
replyMode: "static",
|
|
174
|
+
forwardThinking: true,
|
|
175
|
+
forwardToolCalls: false,
|
|
176
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
177
|
+
} as never,
|
|
178
|
+
client,
|
|
179
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
180
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
185
|
+
|
|
186
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
to: { id: "chat-1", type: "direct" },
|
|
189
|
+
body: { fragments: [{ kind: "text", text: "Error: boom" }] },
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("strips delivery retry wrapper text before sending non-streaming errors", async () => {
|
|
196
|
+
let hooks:
|
|
197
|
+
| {
|
|
198
|
+
onError?: (error: unknown, info: { kind: string }) => void;
|
|
199
|
+
}
|
|
200
|
+
| undefined;
|
|
201
|
+
const client = {
|
|
202
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
203
|
+
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
204
|
+
}),
|
|
205
|
+
replyMessage: vi.fn(),
|
|
206
|
+
typing: vi.fn(),
|
|
207
|
+
} as unknown as ClawlingChatClient;
|
|
208
|
+
|
|
209
|
+
createOpenclawClawlingReplyDispatcher({
|
|
210
|
+
cfg: {} as never,
|
|
211
|
+
runtime: {
|
|
212
|
+
channel: {
|
|
213
|
+
reply: {
|
|
214
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
215
|
+
createReplyDispatcherWithTyping: vi.fn((options) => {
|
|
216
|
+
hooks = options;
|
|
217
|
+
return {
|
|
218
|
+
dispatcher: {},
|
|
219
|
+
replyOptions: {},
|
|
220
|
+
markDispatchIdle: vi.fn(),
|
|
221
|
+
};
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
} as never,
|
|
226
|
+
account: {
|
|
227
|
+
accountId: "default",
|
|
228
|
+
userId: "agent-1",
|
|
229
|
+
replyMode: "static",
|
|
230
|
+
forwardThinking: true,
|
|
231
|
+
forwardToolCalls: false,
|
|
232
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
233
|
+
} as never,
|
|
234
|
+
client,
|
|
235
|
+
target: { chatId: "chat-1", chatType: "direct" },
|
|
236
|
+
log: { info: vi.fn(), error: vi.fn() },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
hooks?.onError?.(
|
|
240
|
+
new Error("Retry failed for delivery 123: Error: boom"),
|
|
241
|
+
{ kind: "dispatch" },
|
|
242
|
+
);
|
|
243
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
244
|
+
|
|
245
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
246
|
+
expect.objectContaining({
|
|
247
|
+
to: { id: "chat-1", type: "direct" },
|
|
248
|
+
body: { fragments: [{ kind: "text", text: "Error: boom" }] },
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
});
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
|
13
13
|
import { textToFragments } from "./message-mapper.ts";
|
|
14
14
|
import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
|
|
15
15
|
import { sendOpenclawClawlingText, type OutboundReplyCtx } from "./outbound.ts";
|
|
16
|
+
import { sendStreamingFailure } from "./streaming.ts";
|
|
16
17
|
|
|
17
18
|
export interface ReplyDispatcherOptions {
|
|
18
19
|
cfg: OpenClawConfig;
|
|
@@ -54,6 +55,15 @@ type StreamingReplyHooks = {
|
|
|
54
55
|
onReasoningStream?: (payload: ReplyPayload) => Promise<void>;
|
|
55
56
|
};
|
|
56
57
|
|
|
58
|
+
function normalizeReplyErrorText(error: unknown): string {
|
|
59
|
+
const raw = String(error);
|
|
60
|
+
const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
|
|
61
|
+
if (retryWrapped?.[1]?.trim()) return retryWrapped[1].trim();
|
|
62
|
+
const retryWrappedBare = raw.match(/^Retry failed for delivery [^:]+:\s*(.+)$/s);
|
|
63
|
+
if (retryWrappedBare?.[1]?.trim()) return retryWrappedBare[1].trim();
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
/**
|
|
58
68
|
* Reply dispatcher for openclaw-clawchat.
|
|
59
69
|
*
|
|
@@ -126,6 +136,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
126
136
|
const accumulatedMediaUrls: string[] = [];
|
|
127
137
|
let finalEmitted = false;
|
|
128
138
|
let streamingClosed = false;
|
|
139
|
+
let runFailed = false;
|
|
129
140
|
let runDone = false;
|
|
130
141
|
// `streamCreatedEmitted` is the authoritative guard: once a `message.created`
|
|
131
142
|
// has been emitted for this dispatcher instance, never emit another — even
|
|
@@ -133,6 +144,9 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
133
144
|
// raced the lazy open path.
|
|
134
145
|
let streamCreatedEmitted = false;
|
|
135
146
|
|
|
147
|
+
const mintStreamingMessageId = () =>
|
|
148
|
+
`${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
149
|
+
|
|
136
150
|
const openSessionIfNeeded = () => {
|
|
137
151
|
if (!streamingEnabled || streamingSession || streamCreatedEmitted) return;
|
|
138
152
|
streamCreatedEmitted = true;
|
|
@@ -143,7 +157,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
143
157
|
// which mints its own id. The inbound user message_id lives on
|
|
144
158
|
// `replyTo.msgId`; keeping the two distinct avoids the agent's reply
|
|
145
159
|
// frames shadowing the user turn they answer.
|
|
146
|
-
streamingMessageId =
|
|
160
|
+
streamingMessageId = mintStreamingMessageId();
|
|
147
161
|
streamingSession = openBufferedStreamingSession({
|
|
148
162
|
client,
|
|
149
163
|
routing,
|
|
@@ -352,15 +366,35 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
352
366
|
}
|
|
353
367
|
},
|
|
354
368
|
onError: (error: unknown, info: { kind: string }) => {
|
|
369
|
+
const errorText = normalizeReplyErrorText(error);
|
|
355
370
|
log?.error?.(
|
|
356
|
-
`[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${
|
|
371
|
+
`[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${errorText}`,
|
|
357
372
|
);
|
|
358
|
-
|
|
373
|
+
if (!streamingEnabled) {
|
|
374
|
+
void sendStatic(errorText);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
runFailed = true;
|
|
378
|
+
if (streamingSession && !streamingClosed) {
|
|
379
|
+
void closeStreamingSession("fail", errorText);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (streamingClosed) return;
|
|
383
|
+
streamingClosed = true;
|
|
384
|
+
if (!streamingMessageId) streamingMessageId = mintStreamingMessageId();
|
|
385
|
+
void sendStreamingFailure({
|
|
386
|
+
client,
|
|
387
|
+
routing,
|
|
388
|
+
messageId: streamingMessageId,
|
|
389
|
+
currentSequence: 0,
|
|
390
|
+
reason: errorText,
|
|
391
|
+
});
|
|
359
392
|
},
|
|
360
393
|
onIdle: async () => {
|
|
361
394
|
if (runDone) return;
|
|
362
395
|
runDone = true;
|
|
363
396
|
if (!streamingEnabled) return;
|
|
397
|
+
if (runFailed) return;
|
|
364
398
|
await closeStreamingSession("done");
|
|
365
399
|
await emitFinalConsolidatedMessage();
|
|
366
400
|
},
|
package/src/runtime.test.ts
CHANGED
|
@@ -349,6 +349,156 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
349
349
|
});
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
+
describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
353
|
+
it("marks dispatch idle when reply dispatch fails", async () => {
|
|
354
|
+
const markDispatchIdle = vi.fn();
|
|
355
|
+
const withReplyDispatcher = vi.fn(
|
|
356
|
+
async (opts: { run: () => Promise<unknown>; onSettled?: () => void | Promise<void> }) => {
|
|
357
|
+
try {
|
|
358
|
+
await opts.run();
|
|
359
|
+
} finally {
|
|
360
|
+
await opts.onSettled?.();
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
const dispatchReplyFromConfig = vi.fn().mockRejectedValue(new Error("dispatch boom"));
|
|
365
|
+
const logError = vi.fn();
|
|
366
|
+
|
|
367
|
+
const runtime = {
|
|
368
|
+
channel: {
|
|
369
|
+
routing: {
|
|
370
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
371
|
+
agentId: "u",
|
|
372
|
+
accountId: "default",
|
|
373
|
+
sessionKey: "s",
|
|
374
|
+
})),
|
|
375
|
+
},
|
|
376
|
+
session: {
|
|
377
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
378
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
379
|
+
},
|
|
380
|
+
reply: {
|
|
381
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
382
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
383
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
384
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
385
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
386
|
+
dispatcher: {},
|
|
387
|
+
replyOptions: {},
|
|
388
|
+
markDispatchIdle,
|
|
389
|
+
})),
|
|
390
|
+
withReplyDispatcher,
|
|
391
|
+
dispatchReplyFromConfig,
|
|
392
|
+
},
|
|
393
|
+
media: {
|
|
394
|
+
fetchRemoteMedia: vi.fn(),
|
|
395
|
+
saveMediaBuffer: vi.fn(),
|
|
396
|
+
loadWebMedia: vi.fn(),
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
} as unknown as PluginRuntime;
|
|
400
|
+
|
|
401
|
+
setOpenclawClawlingRuntime(runtime);
|
|
402
|
+
|
|
403
|
+
const { startOpenclawClawlingGateway } = await import("./runtime.ts");
|
|
404
|
+
const transport = new MockTransport();
|
|
405
|
+
const abortController = new AbortController();
|
|
406
|
+
|
|
407
|
+
const startPromise = startOpenclawClawlingGateway({
|
|
408
|
+
cfg: {} as OpenClawConfig,
|
|
409
|
+
account: {
|
|
410
|
+
accountId: "default",
|
|
411
|
+
name: "openclaw-clawchat",
|
|
412
|
+
enabled: true,
|
|
413
|
+
configured: true,
|
|
414
|
+
websocketUrl: "ws://t",
|
|
415
|
+
baseUrl: "https://api.example.com",
|
|
416
|
+
token: "tk",
|
|
417
|
+
userId: "u",
|
|
418
|
+
replyMode: "static",
|
|
419
|
+
forwardThinking: true,
|
|
420
|
+
forwardToolCalls: false,
|
|
421
|
+
allowFrom: [],
|
|
422
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
423
|
+
reconnect: {
|
|
424
|
+
initialDelay: 1000,
|
|
425
|
+
maxDelay: 30000,
|
|
426
|
+
jitterRatio: 0.3,
|
|
427
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
428
|
+
},
|
|
429
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
430
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
431
|
+
},
|
|
432
|
+
abortSignal: abortController.signal,
|
|
433
|
+
setStatus: vi.fn(),
|
|
434
|
+
getStatus: vi.fn(() => ({ accountId: "default" })),
|
|
435
|
+
log: { info: vi.fn(), error: logError },
|
|
436
|
+
transport,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
440
|
+
transport.emitInbound(
|
|
441
|
+
JSON.stringify({
|
|
442
|
+
version: "2",
|
|
443
|
+
event: "connect.challenge",
|
|
444
|
+
trace_id: "tc",
|
|
445
|
+
emitted_at: Date.now(),
|
|
446
|
+
payload: { nonce: "n" },
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
transport.emitInbound(
|
|
450
|
+
JSON.stringify({
|
|
451
|
+
version: "2",
|
|
452
|
+
event: "hello-ok",
|
|
453
|
+
trace_id: "th",
|
|
454
|
+
emitted_at: Date.now(),
|
|
455
|
+
payload: {},
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
459
|
+
|
|
460
|
+
transport.emitInbound(
|
|
461
|
+
JSON.stringify({
|
|
462
|
+
version: "2",
|
|
463
|
+
event: "message.send",
|
|
464
|
+
trace_id: "tm",
|
|
465
|
+
emitted_at: Date.now(),
|
|
466
|
+
chat_id: "chat-1",
|
|
467
|
+
chat_type: "direct",
|
|
468
|
+
to: { id: "u", type: "direct" },
|
|
469
|
+
sender: { sender_id: "user-1", type: "direct", display_name: "User" },
|
|
470
|
+
payload: {
|
|
471
|
+
message_id: "m-fail",
|
|
472
|
+
message_mode: "normal",
|
|
473
|
+
message: {
|
|
474
|
+
body: {
|
|
475
|
+
fragments: [{ kind: "text", text: "hello" }],
|
|
476
|
+
},
|
|
477
|
+
context: { mentions: [], reply: null },
|
|
478
|
+
streaming: {
|
|
479
|
+
status: "static",
|
|
480
|
+
sequence: 0,
|
|
481
|
+
mutation_policy: "sealed",
|
|
482
|
+
started_at: null,
|
|
483
|
+
completed_at: null,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
491
|
+
abortController.abort();
|
|
492
|
+
await startPromise;
|
|
493
|
+
|
|
494
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
495
|
+
expect(markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
496
|
+
expect(logError).toHaveBeenCalledWith(
|
|
497
|
+
expect.stringContaining("openclaw-clawchat message handler error:"),
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
352
502
|
describe("openclaw-clawchat runtime connect flow", () => {
|
|
353
503
|
it("completes connect through MockTransport handshake", async () => {
|
|
354
504
|
const { startOpenclawClawlingGateway } = await import("./runtime.ts");
|
package/src/runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
|
|
|
19
19
|
import { fetchInboundMedia } from "./media-runtime.ts";
|
|
20
20
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
|
|
21
21
|
import { sendStreamingText } from "./streaming.ts";
|
|
22
|
+
import { sendOpenclawClawlingText } from "./outbound.ts";
|
|
22
23
|
|
|
23
24
|
type Log = { info?: (m: string) => void; error?: (m: string) => void };
|
|
24
25
|
|
|
@@ -33,6 +34,28 @@ export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient
|
|
|
33
34
|
return activeClients.get(accountId);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export async function waitForOpenclawClawlingClient(
|
|
38
|
+
accountId: string,
|
|
39
|
+
options: { timeoutMs?: number; pollMs?: number } = {},
|
|
40
|
+
): Promise<ClawlingChatClient> {
|
|
41
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
42
|
+
const pollMs = options.pollMs ?? 100;
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
|
|
45
|
+
for (;;) {
|
|
46
|
+
const client = activeClients.get(accountId);
|
|
47
|
+
if (client && (client as { state?: string }).state === "connected") {
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() >= deadline) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`openclaw-clawchat client did not activate within ${timeoutMs}ms for account ${accountId}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export type ClawlingState =
|
|
37
60
|
| "idle"
|
|
38
61
|
| "connecting"
|
|
@@ -138,8 +161,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
138
161
|
}
|
|
139
162
|
});
|
|
140
163
|
|
|
141
|
-
client.on("message", (env: Envelope) => {
|
|
142
|
-
|
|
164
|
+
client.on("message", async (env: Envelope) => {
|
|
165
|
+
try {
|
|
166
|
+
await dispatchOpenclawClawlingInbound({
|
|
143
167
|
envelope: env as Envelope<DownlinkMessageSendPayload>,
|
|
144
168
|
cfg,
|
|
145
169
|
runtime,
|
|
@@ -238,14 +262,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
238
262
|
log?.info?.(
|
|
239
263
|
`[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
|
|
240
264
|
);
|
|
241
|
-
|
|
242
|
-
dispatcher,
|
|
243
|
-
onSettled: () => markDispatchIdle(),
|
|
244
|
-
run: () =>
|
|
245
|
-
rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
|
|
246
|
-
});
|
|
265
|
+
|
|
247
266
|
try {
|
|
248
|
-
|
|
267
|
+
const dispatchResult = await rt.reply.withReplyDispatcher({
|
|
268
|
+
dispatcher,
|
|
269
|
+
onSettled: () => markDispatchIdle(),
|
|
270
|
+
run: () =>
|
|
271
|
+
rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
|
|
272
|
+
});
|
|
249
273
|
const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
|
|
250
274
|
const queuedFinal = Boolean(
|
|
251
275
|
(dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
|
|
@@ -264,14 +288,24 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
264
288
|
log?.error?.(
|
|
265
289
|
`[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
|
|
266
290
|
);
|
|
267
|
-
|
|
291
|
+
await sendOpenclawClawlingText({
|
|
292
|
+
client,
|
|
293
|
+
account: turn.account,
|
|
294
|
+
to: {
|
|
295
|
+
chatId: turn.peer.id,
|
|
296
|
+
chatType: turn.peer.kind === "group" ? "group" : "direct",
|
|
297
|
+
},
|
|
298
|
+
text: String(err),
|
|
299
|
+
...(turn.replyCtx ? { replyCtx: turn.replyCtx } : {}),
|
|
300
|
+
});
|
|
268
301
|
}
|
|
269
302
|
},
|
|
270
|
-
|
|
303
|
+
})
|
|
304
|
+
} catch (err) {
|
|
271
305
|
log?.error?.(
|
|
272
|
-
`[${accountId}] openclaw-clawchat
|
|
306
|
+
`[${accountId}] openclaw-clawchat message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
|
|
273
307
|
);
|
|
274
|
-
}
|
|
308
|
+
}
|
|
275
309
|
});
|
|
276
310
|
|
|
277
311
|
// `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
|