@newbase-clawchat/openclaw-clawchat 2026.5.12-7 → 2026.5.12-9

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 CHANGED
@@ -64,17 +64,18 @@ openclaw channels login --channel openclaw-clawchat
64
64
 
65
65
  OpenClaw 2026.5.5 can load an npm-installed third-party channel while still
66
66
  omitting it from the `channels add` CLI catalog. If `channels add` fails with
67
- `Unknown channel: openclaw-clawchat`, use `/clawchat-login A1B2C3` after a real
68
- Gateway restart.
67
+ `Unknown channel: openclaw-clawchat`, use `/clawchat-login A1B2C3` after the
68
+ Gateway has loaded the installed plugin through config reload/hot restart, or
69
+ after a manual restart if automatic reload is unavailable.
69
70
 
70
71
  After a successful activation on a running Gateway with config reload, OpenClaw
71
72
  should load the full runtime plugin and start the channel automatically. If the
72
- Gateway only has the setup-only entry loaded, the credential write forces a
73
- Gateway reload/restart instead of a setup-only channel hot restart; after the full
74
- runtime is attached, later channel config changes can hot reload the channel.
75
- Restart the Gateway manually only after installing/updating the plugin when the
76
- automatic restart has not happened, when config reload is disabled, or when the
77
- channel probe does not become healthy:
73
+ Gateway only has the setup-only entry loaded, the credential write lets
74
+ OpenClaw's config watcher hot-reload or hot-restart into the full runtime instead
75
+ of doing a setup-only channel reload; after the full runtime is attached, later
76
+ channel config changes can hot reload the channel. Restart the Gateway manually
77
+ only when config reload/hot restart is disabled or stalled, or when the channel
78
+ probe does not become healthy:
78
79
 
79
80
  ```bash
80
81
  openclaw gateway restart
@@ -89,10 +90,11 @@ openclaw gateway run
89
90
 
90
91
  The `--token` value above is the ClawChat invite code for OpenClaw's generic
91
92
  `channels add` CLI surface on hosts that expose this plugin in the channel
92
- catalog; the setup write only creates the enabled channel skeleton. Persisted
93
- token fields, default `groupMode: "all"`, `plugins.entries.openclaw-clawchat`,
94
- `plugins.allow`, and `tools.alsoAllow` are written together only after the invite
95
- code exchange succeeds. The plugin registers the ClawChat account/media/search/moment tools
93
+ catalog; the setup adapter validates the invite code without persisting a
94
+ pre-credential channel skeleton. Persisted token fields, default
95
+ `groupMode: "all"`, `plugins.entries.openclaw-clawchat`, `plugins.allow`, and
96
+ `tools.alsoAllow` are written together only after the invite code exchange
97
+ succeeds. The plugin registers the ClawChat account/media/search/moment tools
96
98
  with the OpenClaw agent harness at plugin load time, and activation/login
97
99
  preserves existing plugin entry fields, creates `plugins.allow` with
98
100
  `openclaw-clawchat` when it is missing, appends the same id when it already
@@ -103,8 +105,7 @@ Operators who prefer quieter groups can set `groupMode: "mention"`; later
103
105
  credential refreshes preserve that explicit choice.
104
106
  Before activation, account/media tools return a config error instead of
105
107
  disappearing; after activation/login, the channel is enabled and the same tools
106
- read the persisted token/userId after the runtime plugin reloads or the Gateway
107
- restarts.
108
+ read the persisted token/userId after the runtime plugin reloads or hot-restarts.
108
109
 
109
110
  After activation/login, the channel section is enabled and has credentials:
110
111
 
@@ -26,8 +26,8 @@ const configAdapter = createTopLevelChannelConfigAdapter({
26
26
  /**
27
27
  * Invite-code setup adapter used by OpenClaw setup surfaces.
28
28
  *
29
- * `channels add --token` passes the invite code as setup input. The first
30
- * config write only enables the channel; `afterAccountConfigWritten` exchanges
29
+ * `channels add --token` passes the invite code as setup input. The setup
30
+ * write leaves channel config unchanged; `afterAccountConfigWritten` exchanges
31
31
  * the invite code and persists token/userId through the host runtime mutator.
32
32
  */
33
33
  const setupAdapter = {
@@ -43,20 +43,7 @@ const setupAdapter = {
43
43
  }
44
44
  return null;
45
45
  },
46
- applyAccountConfig: ({ cfg }) => {
47
- const channels = (cfg.channels ?? {});
48
- const current = (channels[CHANNEL_ID] ?? {});
49
- const groupMode = current.groupMode === "mention" || current.groupMode === "all"
50
- ? current.groupMode
51
- : "all";
52
- return {
53
- ...cfg,
54
- channels: {
55
- ...channels,
56
- [CHANNEL_ID]: { ...current, enabled: true, groupMode },
57
- },
58
- };
59
- },
46
+ applyAccountConfig: ({ cfg }) => cfg,
60
47
  afterAccountConfigWritten: async ({ cfg, input, runtime }) => {
61
48
  runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten invoked");
62
49
  const code = typeof input.code === "string" && input.code.trim()
@@ -1,9 +1,6 @@
1
1
  import { EVENT, } from "./protocol-types.js";
2
2
  import { extractMediaFragments, fragmentsToText } from "./message-mapper.js";
3
3
  import { hasRenderableText, isInboundMessagePayload } from "./protocol.js";
4
- const DEDUP_MAX = 256;
5
- const dedupSeen = [];
6
- const dedupSet = new Set();
7
4
  function normalizeSender(sender) {
8
5
  if (!sender || typeof sender !== "object")
9
6
  return null;
@@ -39,22 +36,6 @@ function extractMentionIds(fragments) {
39
36
  .map((fragment) => fragment.kind === "mention" ? fragment.user_id : undefined)
40
37
  .filter((userId) => typeof userId === "string" && userId.length > 0);
41
38
  }
42
- export function _resetDedupForTest() {
43
- dedupSeen.length = 0;
44
- dedupSet.clear();
45
- }
46
- function rememberAndCheck(messageId) {
47
- if (dedupSet.has(messageId))
48
- return true;
49
- dedupSet.add(messageId);
50
- dedupSeen.push(messageId);
51
- if (dedupSeen.length > DEDUP_MAX) {
52
- const evict = dedupSeen.shift();
53
- if (evict)
54
- dedupSet.delete(evict);
55
- }
56
- return false;
57
- }
58
39
  /**
59
40
  * Exported for direct unit testing. Direct chats always count as addressed;
60
41
  * group chats require a mention unless config opts into all group messages.
@@ -107,10 +88,6 @@ export async function dispatchOpenclawClawlingInbound(params) {
107
88
  log?.info?.(`[${account.accountId}] openclaw-clawchat skip empty msg=${payload.message_id}`);
108
89
  return;
109
90
  }
110
- if (rememberAndCheck(payload.message_id)) {
111
- log?.info?.(`[${account.accountId}] openclaw-clawchat skip duplicate msg=${payload.message_id}`);
112
- return;
113
- }
114
91
  const rawBody = fragmentsToText(message.body.fragments, {
115
92
  mentionFallbackIds: message.context.mentions,
116
93
  });
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises";
2
2
  import { createOpenclawClawlingApiClient } from "./api-client.js";
3
3
  import { ClawlingApiError } from "./api-types.js";
4
4
  import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, resolveOpenclawClawlingAccount, } from "./config.js";
5
+ import { getClawChatStore } from "./storage.js";
5
6
  /**
6
7
  * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
7
8
  * agent runtime — openclaw's bundled clawchat channel.
@@ -130,6 +131,24 @@ export async function runOpenclawClawlingLogin(params) {
130
131
  const tokenPreview = redactToken(result.access_token);
131
132
  runtime.log(`Updating config: channels.${CHANNEL_ID}.token=${tokenPreview} userId=${result.agent.user_id}${result.refresh_token ? " refreshToken=***" : ""} plugins.entries.${CHANNEL_ID}.enabled=true plugins.allow+=${CHANNEL_ID} …`);
132
133
  await persistLoginConfig(params, result);
134
+ try {
135
+ const store = params.store ??
136
+ getClawChatStore({
137
+ ...(params.dbPath ? { dbPath: params.dbPath } : {}),
138
+ log: { error: runtime.log },
139
+ });
140
+ store.upsertActivation({
141
+ platform: "openclaw",
142
+ accountId: account.accountId,
143
+ userId: result.agent.user_id,
144
+ accessToken: result.access_token,
145
+ refreshToken: result.refresh_token ?? null,
146
+ loginMethod: "login",
147
+ });
148
+ }
149
+ catch {
150
+ runtime.log("openclaw-clawchat sqlite activation persistence failed; login continues.");
151
+ }
133
152
  runtime.log(`Config file updated.`);
134
153
  runtime.log(`openclaw-clawchat login succeeded (user_id=${result.agent.user_id}, nickname=${result.agent.nickname || "-"}).`);
135
154
  }
@@ -5,6 +5,7 @@ import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
5
5
  import { textToFragments } from "./message-mapper.js";
6
6
  import { uploadOutboundMedia } from "./media-runtime.js";
7
7
  import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
8
+ import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
8
9
  import { createAlignedWsQueue } from "./ws-alignment.js";
9
10
  import { formatWsLog } from "./ws-log.js";
10
11
  const alignedOutboundQueues = new WeakMap();
@@ -183,11 +184,13 @@ export async function sendOpenclawClawlingText(params) {
183
184
  // shape lets us build a single uniform array without a per-kind switch.
184
185
  const fragments = [...textFragments, ...richFragments, ...mediaFragments];
185
186
  const useReply = Boolean(params.replyCtx);
187
+ const messageId = params.messageId;
186
188
  let ack;
187
189
  let mode;
188
190
  if (useReply && params.replyCtx) {
189
191
  mode = "reply";
190
192
  const payload = {
193
+ ...(messageId ? { message_id: messageId } : {}),
191
194
  message_mode: "normal",
192
195
  message: {
193
196
  body: { fragments },
@@ -216,6 +219,7 @@ export async function sendOpenclawClawlingText(params) {
216
219
  else {
217
220
  mode = "send";
218
221
  const payload = {
222
+ ...(messageId ? { message_id: messageId } : {}),
219
223
  message_mode: "normal",
220
224
  message: {
221
225
  body: { fragments },
@@ -231,6 +235,9 @@ export async function sendOpenclawClawlingText(params) {
231
235
  ...(params.log ? { log: params.log } : {}),
232
236
  });
233
237
  }
238
+ if (messageId && ack.payload.message_id !== messageId) {
239
+ throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
240
+ }
234
241
  params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
235
242
  return {
236
243
  messageId: ack.payload.message_id,
@@ -257,11 +264,48 @@ export async function sendOpenclawClawlingMedia(params) {
257
264
  to: params.to,
258
265
  text: params.text ?? "",
259
266
  mediaFragments: params.mediaFragments,
267
+ ...(params.messageId ? { messageId: params.messageId } : {}),
260
268
  ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
261
269
  ...(params.mentions ? { mentions: params.mentions } : {}),
262
270
  ...(params.log ? { log: params.log } : {}),
263
271
  });
264
272
  }
273
+ function mintOutboundMessageId(account) {
274
+ return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
275
+ }
276
+ function resolveChannelOutboundStore() {
277
+ try {
278
+ const runtime = getOpenclawClawlingRuntime();
279
+ const stateDir = runtime.state?.resolveStateDir?.();
280
+ return getClawChatStore({
281
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
282
+ });
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ }
288
+ function claimChannelOutbound(params) {
289
+ const store = resolveChannelOutboundStore();
290
+ if (!store)
291
+ return null;
292
+ try {
293
+ return store.claimMessageOnce({
294
+ platform: "openclaw",
295
+ accountId: params.account.accountId,
296
+ kind: "message",
297
+ direction: "outbound",
298
+ eventType: "message.send",
299
+ chatId: params.target.chatId,
300
+ messageId: params.messageId,
301
+ text: params.text,
302
+ raw: params.raw,
303
+ });
304
+ }
305
+ catch {
306
+ return null;
307
+ }
308
+ }
265
309
  export const openclawClawlingOutbound = {
266
310
  deliveryMode: "direct",
267
311
  chunker: (text, limit) => chunkMarkdownText(text, limit),
@@ -273,15 +317,35 @@ export const openclawClawlingOutbound = {
273
317
  const account = resolveOpenclawClawlingAccount(cfg);
274
318
  const client = getOpenclawClawlingClient(account.accountId) ??
275
319
  (await waitForOpenclawClawlingClient(account.accountId));
320
+ const target = parseOpenclawRecipient(to);
321
+ const messageId = mintOutboundMessageId(account);
322
+ const trimmedText = text.trim();
323
+ if (!trimmedText) {
324
+ throw new Error("openclaw-clawchat sendText requires non-empty text");
325
+ }
326
+ const claimed = claimChannelOutbound({
327
+ account,
328
+ target,
329
+ messageId,
330
+ text: trimmedText,
331
+ raw: { target, mode: "channel-sendText" },
332
+ });
333
+ if (claimed === false) {
334
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
335
+ }
336
+ if (claimed === null) {
337
+ throw new Error("openclaw-clawchat outbound message claim failed");
338
+ }
276
339
  const result = await sendOpenclawClawlingText({
277
340
  client,
278
341
  account,
279
- to: parseOpenclawRecipient(to),
342
+ to: target,
280
343
  text,
344
+ messageId,
281
345
  });
282
346
  return {
283
347
  to,
284
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
348
+ messageId: result?.messageId ?? messageId,
285
349
  };
286
350
  },
287
351
  sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
@@ -307,16 +371,33 @@ export const openclawClawlingOutbound = {
307
371
  if (mediaFragments.length === 0) {
308
372
  throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
309
373
  }
374
+ const target = parseOpenclawRecipient(to);
375
+ const messageId = mintOutboundMessageId(account);
376
+ const claimText = (text ?? "").trim();
377
+ const claimed = claimChannelOutbound({
378
+ account,
379
+ target,
380
+ messageId,
381
+ text: claimText,
382
+ raw: { target, mode: "channel-sendMedia", mediaCount: mediaFragments.length },
383
+ });
384
+ if (claimed === false) {
385
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
386
+ }
387
+ if (claimed === null) {
388
+ throw new Error("openclaw-clawchat outbound message claim failed");
389
+ }
310
390
  const result = await sendOpenclawClawlingMedia({
311
391
  client,
312
392
  account,
313
- to: parseOpenclawRecipient(to),
393
+ to: target,
314
394
  text,
315
395
  mediaFragments,
396
+ messageId,
316
397
  });
317
398
  return {
318
399
  to,
319
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
400
+ messageId: result?.messageId ?? messageId,
320
401
  };
321
402
  },
322
403
  }),
@@ -5,7 +5,7 @@ import { openBufferedStreamingSession, mergeStreamingText, } from "./buffered-st
5
5
  import { emitFinalStreamReply } from "./client.js";
6
6
  import { textToFragments } from "./message-mapper.js";
7
7
  import { uploadOutboundMedia } from "./media-runtime.js";
8
- import { sendOpenclawClawlingText } from "./outbound.js";
8
+ import { sendOpenclawClawlingText, } from "./outbound.js";
9
9
  import { sendStreamingFailure } from "./streaming.js";
10
10
  const CLIENT_SAFE_REPLY_FAILURE_TEXT = "OpenClaw could not complete this reply.";
11
11
  function normalizeReplyErrorText(error) {
@@ -119,7 +119,7 @@ function resolvePayloadText(payload) {
119
119
  * `message.reply` per deliver with text + media.
120
120
  */
121
121
  export function createOpenclawClawlingReplyDispatcher(options) {
122
- const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, inboundForFinalReply, log, } = options;
122
+ const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, inboundForFinalReply, store, log, } = options;
123
123
  const routing = { chatId: target.chatId, chatType: target.chatType };
124
124
  const humanDelay = runtime.channel.reply.resolveHumanDelayConfig(cfg, account.userId);
125
125
  const streamingEnabled = account.replyMode === "stream" && !replyCtx;
@@ -153,16 +153,87 @@ export function createOpenclawClawlingReplyDispatcher(options) {
153
153
  let streamingClosed = false;
154
154
  let runFailed = false;
155
155
  let runDone = false;
156
+ let streamClaimAttempted = false;
156
157
  // `streamCreatedEmitted` is the authoritative guard: once a `message.created`
157
158
  // has been emitted for this dispatcher instance, never emit another — even
158
159
  // if `onReplyStart` fires again or a pre-onReplyStart `onPartialReply`
159
160
  // raced the lazy open path.
160
161
  let streamCreatedEmitted = false;
162
+ const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
163
+ const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
164
+ const claimOutbound = (eventType, messageId, text, raw) => {
165
+ if (!store || !messageId)
166
+ return null;
167
+ try {
168
+ return store.claimMessageOnce({
169
+ platform: "openclaw",
170
+ accountId: account.accountId,
171
+ kind: "message",
172
+ direction: "outbound",
173
+ eventType,
174
+ chatId: target.chatId,
175
+ messageId,
176
+ text,
177
+ raw,
178
+ });
179
+ }
180
+ catch {
181
+ log?.error?.(`[${account.accountId}] openclaw-clawchat sqlite outbound claim failed`);
182
+ return null;
183
+ }
184
+ };
185
+ const updateOutbound = (eventType, messageId, text, raw) => {
186
+ if (!store || !messageId)
187
+ return;
188
+ try {
189
+ store.updateMessageByIdentity({
190
+ accountId: account.accountId,
191
+ kind: "message",
192
+ direction: "outbound",
193
+ eventType,
194
+ chatId: target.chatId,
195
+ messageId,
196
+ text,
197
+ raw,
198
+ });
199
+ }
200
+ catch {
201
+ log?.error?.(`[${account.accountId}] openclaw-clawchat sqlite outbound update failed; continuing`);
202
+ }
203
+ };
204
+ const recordOutbound = (kind, messageId, text) => {
205
+ if (!store || !messageId)
206
+ return;
207
+ try {
208
+ store.insertMessage({
209
+ platform: "openclaw",
210
+ accountId: account.accountId,
211
+ kind,
212
+ direction: "outbound",
213
+ eventType: outboundEventType(),
214
+ chatId: target.chatId,
215
+ messageId,
216
+ text,
217
+ raw: outboundRaw(),
218
+ });
219
+ }
220
+ catch {
221
+ log?.error?.(`[${account.accountId}] openclaw-clawchat sqlite outbound insert failed; continuing`);
222
+ }
223
+ };
224
+ const recordThinkingIfLinked = (messageId) => {
225
+ const thinkingText = reasoningText.trim();
226
+ if (!thinkingText)
227
+ return;
228
+ recordOutbound("thinking", messageId, thinkingText);
229
+ reasoningText = "";
230
+ };
161
231
  const mintStreamingMessageId = () => `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
232
+ const mintStaticMessageId = () => `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
162
233
  const openSessionIfNeeded = () => {
163
- if (!streamingEnabled || streamingSession || streamCreatedEmitted)
234
+ if (!streamingEnabled || streamingSession || streamCreatedEmitted || streamClaimAttempted)
164
235
  return;
165
- streamCreatedEmitted = true;
236
+ streamClaimAttempted = true;
166
237
  // Mint a fresh agent-side message_id at `message.created` time. All
167
238
  // subsequent `message.add` / `message.done` / `message.reply` frames for
168
239
  // this stream reuse it. Once the stream finalizes (done or reply), this
@@ -171,6 +242,14 @@ export function createOpenclawClawlingReplyDispatcher(options) {
171
242
  // `replyTo.msgId`; keeping the two distinct avoids the agent's reply
172
243
  // frames shadowing the user turn they answer.
173
244
  streamingMessageId = mintStreamingMessageId();
245
+ const claimed = claimOutbound("message.created", streamingMessageId, "", { target, replyCtx: replyCtx ?? null, mode: "stream" });
246
+ if (claimed !== true) {
247
+ streamCreatedEmitted = false;
248
+ streamingMessageId = "";
249
+ log?.[claimed === false ? "info" : "error"]?.(`[${account.accountId}] openclaw-clawchat stream outbound skipped reason=${claimed === false ? "duplicate" : "claim_unavailable"}`);
250
+ return;
251
+ }
252
+ streamCreatedEmitted = true;
174
253
  streamingSession = openBufferedStreamingSession({
175
254
  client,
176
255
  routing,
@@ -217,21 +296,36 @@ export function createOpenclawClawlingReplyDispatcher(options) {
217
296
  log?.info?.(`[${account.accountId}] openclaw-clawchat streaming closed msg=${streamingMessageId} reason=${reason ?? "done"}`);
218
297
  };
219
298
  // ----- Static send ------------------------------------------------------
220
- const sendStatic = async (text, mediaFragments = [], richFragments = []) => {
299
+ const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
221
300
  if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0)
222
- return;
301
+ return null;
223
302
  log?.info?.(`[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
224
- await sendOpenclawClawlingText({
303
+ const messageId = mintStaticMessageId();
304
+ const raw = { target, replyCtx: replyCtx ?? null, mode: "static" };
305
+ const claimed = options.recordMessage
306
+ ? claimOutbound(outboundEventType(), messageId, text, raw)
307
+ : true;
308
+ if (claimed === false) {
309
+ log?.info?.(`[${account.accountId}] openclaw-clawchat outbound duplicate skipped msg=${messageId}`);
310
+ return null;
311
+ }
312
+ if (claimed === null) {
313
+ log?.error?.(`[${account.accountId}] openclaw-clawchat outbound skipped msg=${messageId} reason=claim_unavailable`);
314
+ return null;
315
+ }
316
+ const result = await sendOpenclawClawlingText({
225
317
  client,
226
318
  account,
227
319
  to: target,
228
320
  text,
321
+ messageId,
229
322
  ...(replyCtx ? { replyCtx } : {}),
230
323
  ...(richFragments.length > 0 ? { richFragments } : {}),
231
324
  ...(mediaFragments.length > 0 ? { mediaFragments } : {}),
232
325
  log,
233
326
  });
234
327
  log?.info?.(`[${account.accountId}] openclaw-clawchat send complete to=${target.chatId}`);
328
+ return result;
235
329
  };
236
330
  const logDetachedFailure = (action, error) => {
237
331
  log?.error?.(`[${account.accountId}] openclaw-clawchat ${action} failed: ${String(error)}`);
@@ -243,6 +337,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
243
337
  if (finalEmitted)
244
338
  return;
245
339
  finalEmitted = true;
340
+ if (!streamingMessageId)
341
+ return;
246
342
  const mergedMedia = await uploadMediaUrls(accumulatedMediaUrls.slice());
247
343
  const mergedText = streamText.trim();
248
344
  if (!mergedText && finalRichFragments.length === 0 && mergedMedia.length === 0) {
@@ -272,6 +368,8 @@ export function createOpenclawClawlingReplyDispatcher(options) {
272
368
  },
273
369
  body: { fragments: bodyFragments },
274
370
  });
371
+ updateOutbound("message.reply", streamingMessageId, mergedText, { target, replyCtx: replyCtx ?? null, mode: "stream-final" });
372
+ recordThinkingIfLinked(streamingMessageId);
275
373
  };
276
374
  const ingestFinalPayload = (payload, text, richFragment) => {
277
375
  if (richFragment && account.richInteractions) {
@@ -328,7 +426,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
328
426
  await queueStreamSnapshot();
329
427
  }
330
428
  else {
331
- await sendStatic(text);
429
+ reasoningText = mergeStreamingText(reasoningText, text);
430
+ const result = await sendStatic(text, [], [], { recordMessage: true });
431
+ if (result?.messageId)
432
+ recordThinkingIfLinked(result.messageId);
332
433
  }
333
434
  return;
334
435
  }
@@ -338,9 +439,19 @@ export function createOpenclawClawlingReplyDispatcher(options) {
338
439
  ingestFinalPayload(payload, text, richFragment && account.richInteractions ? richFragment : null);
339
440
  // For streaming: consolidated final is emitted in onIdle after done.
340
441
  // For static: emit immediately.
341
- if (!streamingEnabled) {
442
+ if (streamingEnabled) {
443
+ if (text.trim()) {
444
+ await queueStreamSnapshot();
445
+ }
446
+ else if (richFragment || urls.length > 0) {
447
+ openSessionIfNeeded();
448
+ }
449
+ }
450
+ else {
342
451
  const mediaFragments = await uploadMediaUrls(urls);
343
- await sendStatic(text, mediaFragments, richFragment && account.richInteractions ? [richFragment] : []);
452
+ const result = await sendStatic(text, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
453
+ if (result?.messageId)
454
+ recordThinkingIfLinked(result.messageId);
344
455
  }
345
456
  return;
346
457
  }
@@ -352,14 +463,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
352
463
  const mediaFragments = await uploadMediaUrls(urls);
353
464
  if (mediaFragments.length > 0) {
354
465
  log?.info?.(`[${account.accountId}] openclaw-clawchat mid-stream media emitted as separate message (count=${mediaFragments.length})`);
355
- await sendOpenclawClawlingText({
356
- client,
357
- account,
358
- to: target,
359
- text: "",
360
- mediaFragments,
361
- log,
362
- });
466
+ await sendStatic("", mediaFragments, [], { recordMessage: true });
363
467
  }
364
468
  }
365
469
  }
@@ -367,7 +471,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
367
471
  const mediaFragments = await uploadMediaUrls(urls);
368
472
  const richFragments = richFragment && account.richInteractions ? [richFragment] : [];
369
473
  if (text.trim() || mediaFragments.length > 0 || richFragments.length > 0) {
370
- await sendStatic(text, mediaFragments, richFragments);
474
+ await sendStatic(text, mediaFragments, richFragments, { recordMessage: true });
371
475
  }
372
476
  }
373
477
  },