@newbase-clawchat/openclaw-clawchat 2026.4.21 → 2026.4.23
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/package.json +3 -5
- package/src/api-client.test.ts +19 -0
- package/src/api-client.ts +2 -2
- package/src/api-types.ts +3 -3
- package/src/channel.outbound.test.ts +203 -0
- package/src/channel.test.ts +19 -0
- package/src/channel.ts +16 -72
- package/src/inbound.test.ts +17 -0
- package/src/inbound.ts +3 -1
- package/src/media-runtime.test.ts +9 -16
- package/src/media-runtime.ts +4 -4
- package/src/outbound.ts +115 -2
- package/src/protocol.test.ts +3 -1
- package/src/protocol.ts +7 -3
- package/src/reply-dispatcher.test.ts +252 -0
- package/src/reply-dispatcher.ts +37 -3
- package/src/runtime.test.ts +298 -0
- package/src/runtime.ts +53 -11
- package/src/tools-schema.ts +10 -2
- package/src/tools.test.ts +31 -2
- package/src/tools.ts +59 -15
package/src/outbound.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { ClawlingChatClient, Envelope, Fragment, MessageAckPayload } from "@newbase-clawchat/sdk";
|
|
2
|
+
import {
|
|
3
|
+
createAttachedChannelResultAdapter,
|
|
4
|
+
type ChannelOutboundAdapter,
|
|
5
|
+
} from "openclaw/plugin-sdk/channel-send-result";
|
|
6
|
+
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
|
2
7
|
import type { ChatType } from "./client.ts";
|
|
3
|
-
import
|
|
4
|
-
import type
|
|
8
|
+
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
9
|
+
import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
5
10
|
import { textToFragments } from "./message-mapper.ts";
|
|
11
|
+
import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
|
|
12
|
+
import {
|
|
13
|
+
getOpenclawClawlingClient,
|
|
14
|
+
getOpenclawClawlingRuntime,
|
|
15
|
+
waitForOpenclawClawlingClient,
|
|
16
|
+
} from "./runtime.ts";
|
|
6
17
|
|
|
7
18
|
export interface OutboundTarget {
|
|
8
19
|
chatId: string;
|
|
@@ -37,6 +48,48 @@ export interface SendResult {
|
|
|
37
48
|
acceptedAt: number;
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Parse an agent-initiated outbound recipient string into the new-protocol
|
|
53
|
+
* `chat_id` + `chat_type` pair.
|
|
54
|
+
*
|
|
55
|
+
* Accepted forms (case-insensitive prefix):
|
|
56
|
+
* - `cc:{chat_id}` → direct
|
|
57
|
+
* - `clawchat:{chat_id}` → direct
|
|
58
|
+
* - `openclaw-clawchat:{chat_id}` → direct
|
|
59
|
+
* - `cc:direct:{chat_id}` → direct
|
|
60
|
+
* - `cc:group:{chat_id}` → group
|
|
61
|
+
* - `clawchat:direct:{chat_id}` → direct
|
|
62
|
+
* - `clawchat:group:{chat_id}` → group
|
|
63
|
+
* - `openclaw-clawchat:direct:{chat_id}` → direct
|
|
64
|
+
* - `openclaw-clawchat:group:{chat_id}` → group
|
|
65
|
+
* - bare `{chat_id}` → direct (backward compat)
|
|
66
|
+
*/
|
|
67
|
+
export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
|
|
68
|
+
const raw = (to ?? "").trim();
|
|
69
|
+
if (!raw) throw new Error("openclaw-clawchat: outbound `to` is empty");
|
|
70
|
+
|
|
71
|
+
const firstColon = raw.indexOf(":");
|
|
72
|
+
if (firstColon < 0) return { chatId: raw, chatType: "direct" };
|
|
73
|
+
|
|
74
|
+
const scheme = raw.slice(0, firstColon).toLowerCase();
|
|
75
|
+
const rest = raw.slice(firstColon + 1);
|
|
76
|
+
if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
|
|
77
|
+
return { chatId: raw, chatType: "direct" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const secondColon = rest.indexOf(":");
|
|
81
|
+
if (secondColon >= 0) {
|
|
82
|
+
const typeToken = rest.slice(0, secondColon).toLowerCase();
|
|
83
|
+
const chatId = rest.slice(secondColon + 1).trim();
|
|
84
|
+
if ((typeToken === "direct" || typeToken === "group") && chatId) {
|
|
85
|
+
return { chatId, chatType: typeToken };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const chatId = rest.trim();
|
|
89
|
+
if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
|
|
90
|
+
return { chatId, chatType: "direct" };
|
|
91
|
+
}
|
|
92
|
+
|
|
40
93
|
export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
|
|
41
94
|
const text = (params.text ?? "").trim();
|
|
42
95
|
const mediaFragments = params.mediaFragments ?? [];
|
|
@@ -139,3 +192,63 @@ export async function sendOpenclawClawlingMedia(
|
|
|
139
192
|
...(params.log ? { log: params.log } : {}),
|
|
140
193
|
});
|
|
141
194
|
}
|
|
195
|
+
|
|
196
|
+
export const openclawClawlingOutbound: ChannelOutboundAdapter = {
|
|
197
|
+
deliveryMode: "direct",
|
|
198
|
+
chunker: (text, limit) => chunkMarkdownText(text, limit),
|
|
199
|
+
chunkerMode: "markdown",
|
|
200
|
+
textChunkLimit: 4000,
|
|
201
|
+
...createAttachedChannelResultAdapter({
|
|
202
|
+
channel: CHANNEL_ID,
|
|
203
|
+
sendText: async ({ cfg, to, text }) => {
|
|
204
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
205
|
+
const client =
|
|
206
|
+
getOpenclawClawlingClient(account.accountId) ??
|
|
207
|
+
(await waitForOpenclawClawlingClient(account.accountId));
|
|
208
|
+
const result = await sendOpenclawClawlingText({
|
|
209
|
+
client,
|
|
210
|
+
account,
|
|
211
|
+
to: parseOpenclawRecipient(to),
|
|
212
|
+
text,
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
to,
|
|
216
|
+
messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
|
|
220
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
221
|
+
const client =
|
|
222
|
+
getOpenclawClawlingClient(account.accountId) ??
|
|
223
|
+
(await waitForOpenclawClawlingClient(account.accountId));
|
|
224
|
+
if (!mediaUrl?.trim()) {
|
|
225
|
+
throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
|
|
226
|
+
}
|
|
227
|
+
const runtime = getOpenclawClawlingRuntime();
|
|
228
|
+
const apiClient = createOpenclawClawlingApiClient({
|
|
229
|
+
baseUrl: account.baseUrl,
|
|
230
|
+
token: account.token,
|
|
231
|
+
userId: account.userId,
|
|
232
|
+
});
|
|
233
|
+
const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
|
|
234
|
+
apiClient,
|
|
235
|
+
runtime,
|
|
236
|
+
...(mediaLocalRoots ? { mediaLocalRoots } : {}),
|
|
237
|
+
});
|
|
238
|
+
if (mediaFragments.length === 0) {
|
|
239
|
+
throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
|
|
240
|
+
}
|
|
241
|
+
const result = await sendOpenclawClawlingMedia({
|
|
242
|
+
client,
|
|
243
|
+
account,
|
|
244
|
+
to: parseOpenclawRecipient(to),
|
|
245
|
+
text,
|
|
246
|
+
mediaFragments,
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
to,
|
|
250
|
+
messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
};
|
package/src/protocol.test.ts
CHANGED
|
@@ -36,7 +36,9 @@ describe("openclaw-clawchat protocol guards", () => {
|
|
|
36
36
|
|
|
37
37
|
it("detects renderable text", () => {
|
|
38
38
|
expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: "hi" }] } })).toBe(true);
|
|
39
|
-
expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(
|
|
39
|
+
expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(true);
|
|
40
|
+
expect(hasRenderableText({ body: { fragments: [{ kind: "file", url: "x" }] } })).toBe(true);
|
|
40
41
|
expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: " " }] } })).toBe(false);
|
|
42
|
+
expect(hasRenderableText({ body: { fragments: [{ kind: "image" }] } })).toBe(false);
|
|
41
43
|
});
|
|
42
44
|
});
|
package/src/protocol.ts
CHANGED
|
@@ -31,8 +31,12 @@ export function hasRenderableText(message: {
|
|
|
31
31
|
const fragments = message?.body?.fragments ?? [];
|
|
32
32
|
return fragments.some(
|
|
33
33
|
(f) =>
|
|
34
|
-
(f as { kind?: string }).kind === "text" &&
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
(((f as { kind?: string }).kind === "text" &&
|
|
35
|
+
typeof (f as { text?: unknown }).text === "string" &&
|
|
36
|
+
(f as { text: string }).text.trim().length > 0) ||
|
|
37
|
+
(typeof (f as { kind?: unknown }).kind === "string" &&
|
|
38
|
+
["image", "file", "audio", "video"].includes((f as { kind: string }).kind) &&
|
|
39
|
+
typeof (f as { url?: unknown }).url === "string" &&
|
|
40
|
+
(f as { url: string }).url.trim().length > 0)),
|
|
37
41
|
);
|
|
38
42
|
}
|
|
@@ -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
|
},
|