@newbase-clawchat/openclaw-clawchat 2026.4.15

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.
@@ -0,0 +1,141 @@
1
+ import type { ClawlingChatClient, Envelope, Fragment, MessageAckPayload } from "@newbase-clawchat/sdk";
2
+ import type { ChatType } from "./client.ts";
3
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
4
+ import type { ClawlingMediaFragment } from "./media-runtime.ts";
5
+ import { textToFragments } from "./message-mapper.ts";
6
+
7
+ export interface OutboundTarget {
8
+ chatId: string;
9
+ chatType: ChatType;
10
+ }
11
+
12
+ export interface OutboundReplyCtx {
13
+ replyToMessageId: string;
14
+ replyPreviewSenderId: string;
15
+ replyPreviewNickName: string;
16
+ replyPreviewText: string;
17
+ }
18
+
19
+ export interface LogSink {
20
+ info?: (m: string) => void;
21
+ error?: (m: string) => void;
22
+ }
23
+
24
+ export interface SendParams {
25
+ client: ClawlingChatClient;
26
+ account: ResolvedOpenclawClawlingAccount;
27
+ to: OutboundTarget;
28
+ text: string;
29
+ replyCtx?: OutboundReplyCtx;
30
+ mediaFragments?: ClawlingMediaFragment[];
31
+ mentions?: string[];
32
+ log?: LogSink;
33
+ }
34
+
35
+ export interface SendResult {
36
+ messageId: string;
37
+ acceptedAt: number;
38
+ }
39
+
40
+ export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
41
+ const text = (params.text ?? "").trim();
42
+ const mediaFragments = params.mediaFragments ?? [];
43
+ if (!text && mediaFragments.length === 0) {
44
+ params.log?.info?.(
45
+ `[${params.account.accountId}] openclaw-clawchat outbound suppressed: empty text and no media`,
46
+ );
47
+ return null;
48
+ }
49
+
50
+ const mentions = params.mentions ?? [];
51
+ const textFragments = text ? textToFragments(text) : [];
52
+ // Cast at the SDK boundary: each MediaItem object is structurally compatible
53
+ // with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
54
+ // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
55
+ // shape lets us build a single uniform array without a per-kind switch.
56
+ const fragments = [...textFragments, ...mediaFragments] as Fragment[];
57
+
58
+ const useReply = params.replyCtx && mediaFragments.length === 0;
59
+ if (params.replyCtx && mediaFragments.length > 0) {
60
+ params.log?.info?.(
61
+ `[${params.account.accountId}] openclaw-clawchat replyCtx + media: downgraded to sendMessage`,
62
+ );
63
+ }
64
+
65
+ let ack: Envelope<MessageAckPayload>;
66
+ let mode: "send" | "reply";
67
+ if (useReply && params.replyCtx) {
68
+ mode = "reply";
69
+ ack = await params.client.replyMessage({
70
+ chat_id: params.to.chatId,
71
+ chat_type: params.to.chatType,
72
+ mode: "normal",
73
+ replyTo: {
74
+ msgId: params.replyCtx.replyToMessageId,
75
+ senderId: params.replyCtx.replyPreviewSenderId,
76
+ nickName: params.replyCtx.replyPreviewNickName,
77
+ fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
78
+ },
79
+ body: { fragments },
80
+ context: { mentions },
81
+ });
82
+ } else {
83
+ mode = "send";
84
+ ack = await params.client.sendMessage({
85
+ chat_id: params.to.chatId,
86
+ chat_type: params.to.chatType,
87
+ mode: "normal",
88
+ body: { fragments },
89
+ context: { mentions, reply: null },
90
+ });
91
+ }
92
+ params.log?.info?.(
93
+ `[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`,
94
+ );
95
+ return {
96
+ messageId: ack.payload.message_id,
97
+ acceptedAt: ack.payload.accepted_at,
98
+ };
99
+ }
100
+
101
+ export interface SendMediaParams {
102
+ client: ClawlingChatClient;
103
+ account: ResolvedOpenclawClawlingAccount;
104
+ to: OutboundTarget;
105
+ mediaFragments: ClawlingMediaFragment[];
106
+ /** Optional caption alongside the media. */
107
+ text?: string;
108
+ replyCtx?: OutboundReplyCtx;
109
+ mentions?: string[];
110
+ log?: LogSink;
111
+ }
112
+
113
+ /**
114
+ * Send one or more media fragments (image / file / audio / video) to the
115
+ * given target, with an optional text caption.
116
+ *
117
+ * Validates that mediaFragments is non-empty (returns null + info log
118
+ * otherwise) and delegates to {@link sendOpenclawClawlingText} for the
119
+ * actual envelope construction. Reuses the existing replyCtx-downgrade,
120
+ * ack backfill, and log shape.
121
+ */
122
+ export async function sendOpenclawClawlingMedia(
123
+ params: SendMediaParams,
124
+ ): Promise<SendResult | null> {
125
+ if (params.mediaFragments.length === 0) {
126
+ params.log?.info?.(
127
+ `[${params.account.accountId}] openclaw-clawchat sendMedia called with empty mediaFragments; suppressed`,
128
+ );
129
+ return null;
130
+ }
131
+ return await sendOpenclawClawlingText({
132
+ client: params.client,
133
+ account: params.account,
134
+ to: params.to,
135
+ text: params.text ?? "",
136
+ mediaFragments: params.mediaFragments,
137
+ ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
138
+ ...(params.mentions ? { mentions: params.mentions } : {}),
139
+ ...(params.log ? { log: params.log } : {}),
140
+ });
141
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isInboundMessagePayload, isGroupSender, hasRenderableText } from "./protocol.ts";
3
+
4
+ describe("openclaw-clawchat protocol guards", () => {
5
+ it("accepts a well-formed downlink message payload", () => {
6
+ const payload = {
7
+ message_id: "m1",
8
+ message_mode: "normal",
9
+ message: {
10
+ body: { fragments: [{ kind: "text", text: "hi" }] },
11
+ context: { mentions: [], reply: null },
12
+ streaming: {
13
+ status: "static",
14
+ sequence: 0,
15
+ mutation_policy: "sealed",
16
+ started_at: null,
17
+ completed_at: null,
18
+ },
19
+ },
20
+ };
21
+ expect(isInboundMessagePayload(payload)).toBe(true);
22
+ });
23
+
24
+ it("rejects payload missing message_id", () => {
25
+ expect(isInboundMessagePayload({ message: { body: { fragments: [] } } })).toBe(false);
26
+ });
27
+
28
+ it("rejects payload missing message", () => {
29
+ expect(isInboundMessagePayload({ message_id: "m1" })).toBe(false);
30
+ });
31
+
32
+ it("detects group sender", () => {
33
+ expect(isGroupSender({ sender_id: "g", type: "group", display_name: "Group" })).toBe(true);
34
+ expect(isGroupSender({ sender_id: "u", type: "direct", display_name: "User" })).toBe(false);
35
+ });
36
+
37
+ it("detects renderable text", () => {
38
+ expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: "hi" }] } })).toBe(true);
39
+ expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(false);
40
+ expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: " " }] } })).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Local narrow guards for inbound protocol envelopes.
3
+ *
4
+ * The SDK's `message` event hands us `Envelope<unknown>`. Before casting the
5
+ * payload to `DownlinkMessageSendPayload` we run these cheap structural checks
6
+ * so runtime errors surface as skipped messages, not crashes.
7
+ */
8
+
9
+ export function isInboundMessagePayload(payload: unknown): payload is {
10
+ message_id: string;
11
+ message_mode: string;
12
+ message: {
13
+ body: { fragments: Array<Record<string, unknown>> };
14
+ context: { mentions: string[]; reply: unknown };
15
+ };
16
+ } {
17
+ if (!payload || typeof payload !== "object") return false;
18
+ const p = payload as Record<string, unknown>;
19
+ if (typeof p.message_id !== "string" || !p.message_id) return false;
20
+ if (typeof p.message !== "object" || p.message === null) return false;
21
+ const m = p.message as Record<string, unknown>;
22
+ if (typeof m.body !== "object" || m.body === null) return false;
23
+ const body = m.body as Record<string, unknown>;
24
+ if (!Array.isArray(body.fragments)) return false;
25
+ return true;
26
+ }
27
+
28
+ export function hasRenderableText(message: {
29
+ body?: { fragments?: Array<Record<string, unknown>> };
30
+ }): boolean {
31
+ const fragments = message?.body?.fragments ?? [];
32
+ return fragments.some(
33
+ (f) =>
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
+ );
38
+ }
@@ -0,0 +1,387 @@
1
+ import type { ClawlingChatClient, Fragment } from "@newbase-clawchat/sdk";
2
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
+ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
5
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
6
+ import {
7
+ openBufferedStreamingSession,
8
+ mergeStreamingText,
9
+ type BufferedStreamSession,
10
+ } from "./buffered-stream.ts";
11
+ import { emitFinalStreamReply, type ChatType, type EnvelopeRouting } from "./client.ts";
12
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
13
+ import { textToFragments } from "./message-mapper.ts";
14
+ import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
15
+ import { sendOpenclawClawlingText, type OutboundReplyCtx } from "./outbound.ts";
16
+
17
+ export interface ReplyDispatcherOptions {
18
+ cfg: OpenClawConfig;
19
+ runtime: PluginRuntime;
20
+ account: ResolvedOpenclawClawlingAccount;
21
+ client: ClawlingChatClient;
22
+ /**
23
+ * New-protocol routing: `chatId` is the subject id, `chatType` is `"direct"`
24
+ * or `"group"`. Every outbound frame carries these as `chat_id` + `chat_type`.
25
+ */
26
+ target: { chatId: string; chatType: ChatType };
27
+ replyCtx?: OutboundReplyCtx;
28
+ /**
29
+ * The `message_id` from the inbound `message.send` envelope that triggered
30
+ * this reply run. When provided, streaming frames (`message.created`,
31
+ * `message.add`, `message.done`) reference this id so the client can
32
+ * correlate the streamed reply with the original user message. When
33
+ * omitted, a locally-generated id is used as a fallback.
34
+ */
35
+ inboundMessageId?: string;
36
+ /**
37
+ * Describes the inbound user message, used as the `replyTo` preview on
38
+ * the consolidated `message.reply` that closes a streaming run.
39
+ */
40
+ inboundForFinalReply?: {
41
+ senderId: string;
42
+ senderNickName: string;
43
+ bodyText: string;
44
+ };
45
+ log?: { info?: (m: string) => void; error?: (m: string) => void };
46
+ }
47
+
48
+ type TypedReplyDispatcherResult = ReturnType<
49
+ PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"]
50
+ >;
51
+
52
+ type StreamingReplyHooks = {
53
+ onPartialReply?: (payload: ReplyPayload) => Promise<void>;
54
+ onReasoningStream?: (payload: ReplyPayload) => Promise<void>;
55
+ };
56
+
57
+ /**
58
+ * Reply dispatcher for openclaw-clawchat.
59
+ *
60
+ * Streaming mode (`account.replyMode === "stream"`, no replyCtx):
61
+ * 1. `onReplyStart` opens a buffered streaming session — `message.created`
62
+ * fires immediately.
63
+ * 2. As the agent runs, `onPartialReply` / `onReasoningStream` snapshots
64
+ * and `deliver(block|tool)` deltas feed into the session; each flush
65
+ * emits `message.add` with the new text delta (chunked by
66
+ * `stream.flushIntervalMs` + `stream.minChunkChars`).
67
+ * 3. On run end (`onIdle`), the session flushes pending buffer, emits
68
+ * `message.done`, then the merged full text plus any accumulated
69
+ * media is emitted as a separate `message.send` / `message.reply` —
70
+ * mirroring the clawling-channel v1 pattern where streaming `agent`
71
+ * events are followed by a consolidated `chat` final.
72
+ *
73
+ * Static mode or replyCtx: bypass streaming and emit one `message.send` /
74
+ * `message.reply` per deliver with text + media.
75
+ */
76
+ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOptions): {
77
+ dispatcher: TypedReplyDispatcherResult["dispatcher"];
78
+ replyOptions: TypedReplyDispatcherResult["replyOptions"] & StreamingReplyHooks;
79
+ markDispatchIdle: TypedReplyDispatcherResult["markDispatchIdle"];
80
+ } {
81
+ const {
82
+ cfg,
83
+ runtime,
84
+ account,
85
+ client,
86
+ target,
87
+ replyCtx,
88
+ inboundMessageId,
89
+ inboundForFinalReply,
90
+ log,
91
+ } = options;
92
+ const routing: EnvelopeRouting = { chatId: target.chatId, chatType: target.chatType };
93
+ const humanDelay = runtime.channel.reply.resolveHumanDelayConfig(cfg, account.userId);
94
+ const streamingEnabled = account.replyMode === "stream" && !replyCtx;
95
+
96
+ const buildApiClient = () => {
97
+ if (!account.baseUrl || !account.token) return null;
98
+ return createOpenclawClawlingApiClient({
99
+ baseUrl: account.baseUrl,
100
+ token: account.token,
101
+ userId: account.userId,
102
+ });
103
+ };
104
+
105
+ async function uploadMediaUrls(urls: string[]): Promise<ClawlingMediaFragment[]> {
106
+ if (urls.length === 0) return [];
107
+ const apiClient = buildApiClient();
108
+ if (!apiClient) {
109
+ log?.info?.(
110
+ `[${account.accountId}] openclaw-clawchat outbound media skipped: baseUrl not configured`,
111
+ );
112
+ return [];
113
+ }
114
+ return await uploadOutboundMedia(urls, { apiClient, runtime, log });
115
+ }
116
+
117
+ // ----- Streaming session state -----------------------------------------
118
+
119
+ let streamingSession: BufferedStreamSession | null = null;
120
+ let streamingMessageId = "";
121
+ let streamText = "";
122
+ let reasoningText = "";
123
+ const accumulatedMediaUrls: string[] = [];
124
+ let finalEmitted = false;
125
+ let streamingClosed = false;
126
+ let runDone = false;
127
+ // `streamCreatedEmitted` is the authoritative guard: once a `message.created`
128
+ // has been emitted for this dispatcher instance, never emit another — even
129
+ // if `onReplyStart` fires again or a pre-onReplyStart `onPartialReply`
130
+ // raced the lazy open path.
131
+ let streamCreatedEmitted = false;
132
+
133
+ const openSessionIfNeeded = () => {
134
+ if (!streamingEnabled || streamingSession || streamCreatedEmitted) return;
135
+ streamCreatedEmitted = true;
136
+ // Use the inbound message_id when available so the streaming frames
137
+ // correlate with the user message that triggered this reply; fall back
138
+ // to a generated id only when not provided (e.g. tests).
139
+ streamingMessageId = inboundMessageId ?? `${account.userId}-${Date.now()}`;
140
+ streamingSession = openBufferedStreamingSession({
141
+ client,
142
+ routing,
143
+ sender: {
144
+ id: account.userId,
145
+ type: target.chatType,
146
+ nick_name: account.userId,
147
+ },
148
+ messageId: streamingMessageId,
149
+ flushIntervalMs: account.stream.flushIntervalMs,
150
+ minChunkChars: account.stream.minChunkChars,
151
+ maxBufferChars: account.stream.maxBufferChars,
152
+ });
153
+ log?.info?.(
154
+ `[${account.accountId}] openclaw-clawchat streaming opened msg=${streamingMessageId}`,
155
+ );
156
+ };
157
+
158
+ const buildCombinedStreamSnapshot = (): string => {
159
+ if (!reasoningText && !streamText) return "";
160
+ if (!reasoningText) return streamText;
161
+ if (!streamText) return reasoningText;
162
+ return `${reasoningText}\n\n${streamText}`;
163
+ };
164
+
165
+ const queueStreamSnapshot = async () => {
166
+ openSessionIfNeeded();
167
+ if (!streamingSession) return;
168
+ const combined = buildCombinedStreamSnapshot();
169
+ if (!combined) return;
170
+ await streamingSession.queueSnapshot(combined);
171
+ };
172
+
173
+ const closeStreamingSession = async (reason?: "done" | "fail", failReason?: string) => {
174
+ if (!streamingSession || streamingClosed) return;
175
+ streamingClosed = true;
176
+ if (reason === "fail") {
177
+ await streamingSession.fail(failReason);
178
+ } else {
179
+ await streamingSession.done();
180
+ }
181
+ log?.info?.(
182
+ `[${account.accountId}] openclaw-clawchat streaming closed msg=${streamingMessageId} reason=${reason ?? "done"}`,
183
+ );
184
+ };
185
+
186
+ // ----- Static send ------------------------------------------------------
187
+
188
+ const sendStatic = async (text: string, mediaFragments: ClawlingMediaFragment[] = []) => {
189
+ if (!text.trim() && mediaFragments.length === 0) return;
190
+ log?.info?.(
191
+ `[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} to=${target.chatId}`,
192
+ );
193
+ await sendOpenclawClawlingText({
194
+ client,
195
+ account,
196
+ to: target,
197
+ text,
198
+ ...(replyCtx ? { replyCtx } : {}),
199
+ ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
200
+ log,
201
+ });
202
+ log?.info?.(
203
+ `[${account.accountId}] openclaw-clawchat send complete to=${target.chatId}`,
204
+ );
205
+ };
206
+
207
+ const emitFinalConsolidatedMessage = async () => {
208
+ if (finalEmitted) return;
209
+ finalEmitted = true;
210
+ const mergedMedia = await uploadMediaUrls(accumulatedMediaUrls.slice());
211
+ const mergedText = streamText.trim();
212
+ if (!mergedText && mergedMedia.length === 0) {
213
+ log?.info?.(
214
+ `[${account.accountId}] openclaw-clawchat no merged final content; skip consolidated reply`,
215
+ );
216
+ return;
217
+ }
218
+ log?.info?.(
219
+ `[${account.accountId}] openclaw-clawchat emitting consolidated final (message.reply) msg=${streamingMessageId} text_len=${mergedText.length} media=${mergedMedia.length}`,
220
+ );
221
+ const bodyFragments: Fragment[] = [
222
+ ...(mergedText ? textToFragments(mergedText) : []),
223
+ // mediaFragments is the local wide shape; cast at SDK boundary as
224
+ // we do in outbound.ts.
225
+ ...(mergedMedia as Fragment[]),
226
+ ];
227
+ // Streaming message_id must match the created/add/done frames so the
228
+ // backend can correlate the consolidated reply with the stream.
229
+ emitFinalStreamReply(client, {
230
+ messageId: streamingMessageId,
231
+ routing,
232
+ replyTo: {
233
+ msgId: inboundMessageId ?? streamingMessageId,
234
+ senderId: inboundForFinalReply?.senderId ?? target.chatId,
235
+ nickName:
236
+ inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
237
+ fragments: inboundForFinalReply?.bodyText
238
+ ? [{ kind: "text", text: inboundForFinalReply.bodyText }]
239
+ : [],
240
+ },
241
+ body: { fragments: bodyFragments },
242
+ });
243
+ };
244
+
245
+ const ingestFinalPayload = (payload: ReplyPayload) => {
246
+ const text = payload.text ?? "";
247
+ if (text) streamText = mergeStreamingText(streamText, text);
248
+ const urls = [
249
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
250
+ ...(payload.mediaUrls ?? []),
251
+ ].filter((u): u is string => Boolean(u));
252
+ for (const url of urls) {
253
+ if (!accumulatedMediaUrls.includes(url)) accumulatedMediaUrls.push(url);
254
+ }
255
+ };
256
+
257
+ const ingestBlockText = async (text: string) => {
258
+ if (!text) return;
259
+ streamText = `${streamText}${text}`;
260
+ await queueStreamSnapshot();
261
+ };
262
+
263
+ // ----- Dispatcher -------------------------------------------------------
264
+
265
+ const base = runtime.channel.reply.createReplyDispatcherWithTyping({
266
+ humanDelay,
267
+ onReplyStart: async () => {
268
+ // Only clear transient accumulators the first time the run starts.
269
+ // If `onReplyStart` fires again during the same dispatcher instance
270
+ // (e.g. typing controller re-entry), we must NOT tear down the stream
271
+ // session — that would cause a second `message.created`.
272
+ if (!streamCreatedEmitted) {
273
+ streamText = "";
274
+ reasoningText = "";
275
+ accumulatedMediaUrls.length = 0;
276
+ finalEmitted = false;
277
+ streamingClosed = false;
278
+ runDone = false;
279
+ }
280
+ if (streamingEnabled) openSessionIfNeeded();
281
+ },
282
+ deliver: async (payload: ReplyPayload, info?: { kind: "tool" | "block" | "final" }) => {
283
+ const text = payload.text ?? "";
284
+ const urls = [
285
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
286
+ ...(payload.mediaUrls ?? []),
287
+ ].filter((u): u is string => Boolean(u));
288
+ log?.info?.(
289
+ `[${account.accountId}] openclaw-clawchat deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`,
290
+ );
291
+
292
+ if (payload.isReasoning) {
293
+ if (!account.forwardThinking) return;
294
+ if (streamingEnabled) {
295
+ reasoningText = mergeStreamingText(reasoningText, text);
296
+ await queueStreamSnapshot();
297
+ } else {
298
+ await sendStatic(text);
299
+ }
300
+ return;
301
+ }
302
+
303
+ if (info?.kind === "tool" && !account.forwardToolCalls) return;
304
+
305
+ if (info?.kind === "final") {
306
+ ingestFinalPayload(payload);
307
+ // For streaming: consolidated final is emitted in onIdle after done.
308
+ // For static: emit immediately.
309
+ if (!streamingEnabled) {
310
+ const mediaFragments = await uploadMediaUrls(urls);
311
+ await sendStatic(text, mediaFragments);
312
+ }
313
+ return;
314
+ }
315
+
316
+ // kind === "block" or "tool" — text during the run
317
+ if (streamingEnabled) {
318
+ if (text) await ingestBlockText(text);
319
+ if (urls.length > 0) {
320
+ const mediaFragments = await uploadMediaUrls(urls);
321
+ if (mediaFragments.length > 0) {
322
+ log?.info?.(
323
+ `[${account.accountId}] openclaw-clawchat mid-stream media emitted as separate message (count=${mediaFragments.length})`,
324
+ );
325
+ await sendOpenclawClawlingText({
326
+ client,
327
+ account,
328
+ to: target,
329
+ text: "",
330
+ mediaFragments,
331
+ log,
332
+ });
333
+ }
334
+ }
335
+ } else {
336
+ const mediaFragments = await uploadMediaUrls(urls);
337
+ if (text.trim() || mediaFragments.length > 0) {
338
+ await sendStatic(text, mediaFragments);
339
+ }
340
+ }
341
+ },
342
+ onError: (error: unknown, info: { kind: string }) => {
343
+ log?.error?.(
344
+ `[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${String(error)}`,
345
+ );
346
+ void closeStreamingSession("fail", String(error));
347
+ },
348
+ onIdle: async () => {
349
+ if (runDone) return;
350
+ runDone = true;
351
+ if (!streamingEnabled) return;
352
+ await closeStreamingSession("done");
353
+ await emitFinalConsolidatedMessage();
354
+ },
355
+ });
356
+
357
+ const streamingHooks: StreamingReplyHooks = streamingEnabled
358
+ ? {
359
+ onPartialReply: async (payload: ReplyPayload) => {
360
+ if (!payload.text || payload.isReasoning) return;
361
+ // onPartialReply gives progressive snapshots of the current assistant
362
+ // message text (not deltas). Use mergeStreamingText so overlapping
363
+ // prefixes collapse into a single growing snapshot.
364
+ streamText = mergeStreamingText(streamText, payload.text);
365
+ await queueStreamSnapshot();
366
+ },
367
+ ...(account.forwardThinking
368
+ ? {
369
+ onReasoningStream: async (payload: ReplyPayload) => {
370
+ if (!payload.text) return;
371
+ reasoningText = mergeStreamingText(reasoningText, payload.text);
372
+ await queueStreamSnapshot();
373
+ },
374
+ }
375
+ : {}),
376
+ }
377
+ : {};
378
+
379
+ return {
380
+ dispatcher: base.dispatcher,
381
+ replyOptions: {
382
+ ...base.replyOptions,
383
+ ...streamingHooks,
384
+ },
385
+ markDispatchIdle: base.markDispatchIdle,
386
+ };
387
+ }