@clawling/clawchat-plugin-openclaw 2026.10.7-2 → 2026.10.8-1

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.
@@ -202,12 +202,20 @@ export async function pullGroupMetadata(params) {
202
202
  targetType: "user",
203
203
  targetId: mapped.userId,
204
204
  });
205
- if (existing.exists)
205
+ // A cached file is kept as is, except that one without profile_type
206
+ // (written before types were recorded) gets just that field filled in:
207
+ // without it the prompt lists a peer agent as a person.
208
+ if (existing.exists && existing.metadata.profile_type)
206
209
  continue;
207
210
  if (!getUser)
208
211
  throw new Error("ClawChat participant metadata pull requires getUserProfile");
209
212
  const profile = await getUser(mapped.userId);
210
- const metadata = userMetadataFromProfile(profile, mapped.userId);
213
+ const fetched = userMetadataFromProfile(profile, mapped.userId);
214
+ if (existing.exists && !fetched.profile_type)
215
+ continue;
216
+ const metadata = existing.exists
217
+ ? { ...existing.metadata, profile_type: fetched.profile_type }
218
+ : fetched;
211
219
  await writeClawChatMetadata(params.memoryRoot, { targetType: "user", targetId: mapped.userId }, metadata);
212
220
  writes.push({ targetType: "user", targetId: mapped.userId });
213
221
  }
@@ -7,7 +7,7 @@ import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-ru
7
7
  import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
8
8
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
9
9
  import { consumeTerminalClawChatSend } from "./terminal-send.js";
10
- import { createReplyStream } from "./reply-stream.js";
10
+ import { createReplyStream, streamableText } from "./reply-stream.js";
11
11
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
12
12
  /**
13
13
  * Re-send cadence for `typing.update {is_typing:true}` while a reply is in
@@ -345,7 +345,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
345
345
  // send, or an idle run with nothing delivered fails it. Host partials are snapshots of
346
346
  // the current assistant message that may be rewritten, so a snapshot that no
347
347
  // longer extends the preview fails the old stream and starts a new one.
348
- const streamEnabled = account.streamReplies === true;
348
+ // Direct chats only, same as Hermes. In a group the server's merged stream
349
+ // copy carries no @, and a receiver that dedupes by message_id keeps
350
+ // whichever copy lands first, so an @-only agent would never wake up.
351
+ const streamEnabled = account.streamReplies === true && !isGroupTarget;
349
352
  let replyStream = null;
350
353
  const failStream = (reason) => {
351
354
  replyStream?.fail(reason);
@@ -356,11 +359,19 @@ export function createOpenclawClawlingReplyDispatcher(options) {
356
359
  replyStream = null;
357
360
  return id;
358
361
  };
362
+ // Latest host snapshot of the current assistant message, including any tail
363
+ // the add throttle held back, so a boundary can close the preview whole.
364
+ let streamPartialText = "";
365
+ // Texts already delivered as their own message at a message boundary; a
366
+ // final that only repeats one of them is not sent again.
367
+ const finalizedPreviewTexts = new Set();
359
368
  const onStreamPartial = (text) => {
360
369
  if (runDone || terminalReplySuppressed)
361
370
  return;
362
- if (replyStream?.diverges(text))
363
- failStream("superseded");
371
+ // A snapshot that no longer extends the preview without a message
372
+ // boundary is a host rewrite: the stream stops growing and the final
373
+ // replaces it in place (same id). It is never retracted.
374
+ streamPartialText = text;
364
375
  replyStream ??= createReplyStream({
365
376
  client,
366
377
  chatId: target.chatId,
@@ -556,6 +567,36 @@ export function createOpenclawClawlingReplyDispatcher(options) {
556
567
  log,
557
568
  });
558
569
  };
570
+ // ----- Stream message boundary -----------------------------------------
571
+ //
572
+ // A new assistant message (after a tool call or a thinking block) means the
573
+ // previewed one is complete. Close it as its own message — `message.done` +
574
+ // `message.reply` under the same id — instead of letting the next snapshot
575
+ // supersede it, so text the recipient already saw stays. A preview still
576
+ // held back (never shown) is just dropped.
577
+ const finalizeStreamAtBoundary = async (reason) => {
578
+ if (!replyStream)
579
+ return;
580
+ if (!replyStream.isOpen()) {
581
+ replyStream = null;
582
+ streamPartialText = "";
583
+ return;
584
+ }
585
+ const decision = streamableText(streamPartialText);
586
+ const text = decision.kind === "ok"
587
+ ? decision.text
588
+ : decision.kind === "poisoned"
589
+ ? streamPartialText
590
+ : replyStream.shownText();
591
+ streamPartialText = "";
592
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw stream boundary reason=${reason} text_len=${text.length} to=${target.chatId}`);
593
+ const result = await sendStatic(text, [], [], { recordMessage: true });
594
+ if (result)
595
+ finalizedPreviewTexts.add(text.trim());
596
+ // Whatever sendStatic did (sent, suppressed as no-reply, skipped after a
597
+ // terminal tool send), the stream is closed now.
598
+ replyStream = null;
599
+ };
559
600
  // ----- Static send ------------------------------------------------------
560
601
  const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
561
602
  const messageMode = options.messageMode ?? "normal";
@@ -772,6 +813,13 @@ export function createOpenclawClawlingReplyDispatcher(options) {
772
813
  failStream("no-reply");
773
814
  return;
774
815
  }
816
+ if (!replyStream?.isOpen() &&
817
+ finalizedPreviewTexts.has(finalText.trim()) &&
818
+ !richFragment &&
819
+ finalUrls.length === 0) {
820
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
821
+ return;
822
+ }
775
823
  const mediaFragments = await uploadMediaUrls(finalUrls);
776
824
  const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
777
825
  if (result?.messageId)
@@ -796,16 +844,31 @@ export function createOpenclawClawlingReplyDispatcher(options) {
796
844
  return;
797
845
  const fallbackText = bufferedOutputText.trim();
798
846
  const fallbackUrls = bufferedOutputUrls.slice();
799
- if (!fallbackText && fallbackUrls.length === 0)
847
+ if (!fallbackText && fallbackUrls.length === 0) {
848
+ // No final came, but a preview was shown: keep it as the reply
849
+ // rather than retracting text the recipient already read.
850
+ if (replyStream?.isOpen())
851
+ await finalizeStreamAtBoundary("idle");
800
852
  return;
853
+ }
801
854
  const mediaFragments = await uploadMediaUrls(fallbackUrls);
802
855
  const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
803
856
  if (result?.messageId)
804
857
  recordThinkingIfLinked(result.messageId);
805
858
  }
806
859
  finally {
807
- // A preview nothing materialized must not linger as an open stream.
808
- failStream("undelivered");
860
+ // Safety net: nothing above claimed or closed an open stream (e.g. the
861
+ // finalize send threw). It must not linger open, and text already
862
+ // shown must not vanish: close it with what was shown (the server
863
+ // materializes that from message.done). Only a preview whose snapshot
864
+ // turned out to be a no-reply is retracted.
865
+ if (replyStream?.isOpen()) {
866
+ if (streamableText(streamPartialText).kind === "poisoned")
867
+ failStream("no-reply");
868
+ else
869
+ claimStreamMessageId(replyStream.shownText());
870
+ }
871
+ replyStream = null;
809
872
  }
810
873
  },
811
874
  onCleanup: () => {
@@ -840,13 +903,22 @@ export function createOpenclawClawlingReplyDispatcher(options) {
840
903
  reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
841
904
  }
842
905
  : undefined,
843
- onToolStart: splitFullOutput
906
+ onToolStart: splitFullOutput || streamEnabled
844
907
  ? async (payload) => {
908
+ if (streamEnabled)
909
+ await finalizeStreamAtBoundary("tool-start");
910
+ if (!splitFullOutput)
911
+ return;
845
912
  if (consumeTerminalSend("tool-start"))
846
913
  return;
847
914
  await emitProcessSegment(formatToolStartSummary(payload));
848
915
  }
849
916
  : undefined,
917
+ onAssistantMessageStart: streamEnabled
918
+ ? async () => {
919
+ await finalizeStreamAtBoundary("assistant-message-start");
920
+ }
921
+ : undefined,
850
922
  onToolResult: splitFullOutput
851
923
  ? async (payload) => {
852
924
  if (consumeTerminalSend("tool-result"))
@@ -11,12 +11,13 @@
11
11
  * and the server materializes its merged reply from the stream. `finish`
12
12
  * therefore takes the final text, sends whatever tail of it the throttle held
13
13
  * back as one last add, and closes with the whole text. A final that does not
14
- * extend what was shown cannot be appended (§8.2) — the stream is failed and
15
- * the final goes out under its own id.
14
+ * extend what was shown cannot be appended (§8.2): `message.done` then closes
15
+ * with the shown text and the final reply, under the same id, replaces it in
16
+ * place, so text the recipient already saw never vanishes.
16
17
  *
17
- * or, when the reply turns out not to be sendable (no-reply token, run
18
- * aborted, nothing delivered): message.created(M) → … → message.failed(M),
19
- * which aborts the stream without materializing anything.
18
+ * or, when the preview must not stay (a no-reply, or the reply went out
19
+ * through a ClawChat tool instead): message.created(M) → … → message.failed(M),
20
+ * which retracts the preview — the recipient keeps nothing of it.
20
21
  *
21
22
  * Streaming frames get no ack (§8.5) and no server-side mention repair, so the
22
23
  * stream only ever carries plain text; mentions, media and rich fragments ride
@@ -184,23 +185,24 @@ export function createReplyStream(options) {
184
185
  return null;
185
186
  }
186
187
  // Trailing whitespace a partial carried may be trimmed off the final.
187
- if (!finalText.startsWith(sent.trimEnd())) {
188
- fail("superseded");
189
- return null;
190
- }
191
- if (finalText.length > sent.length && finalText.startsWith(sent)) {
188
+ const extendsShown = finalText.startsWith(sent.trimEnd());
189
+ if (extendsShown && finalText.length > sent.length && finalText.startsWith(sent)) {
192
190
  // Past the throttle: this is the last frame before done.
193
191
  sendAdd(finalText, now());
194
192
  }
193
+ // A final that rewrites the preview cannot be appended (§8.2), but the
194
+ // shown text must not vanish: close the stream with what was shown and
195
+ // let the final reply under this same id replace it in place.
196
+ const doneText = extendsShown ? finalText : sent;
195
197
  closed = true;
196
198
  const at = now();
197
199
  emit(EVENT.MESSAGE_DONE, {
198
200
  message_id: messageId,
199
- fragments: [{ kind: "text", text: finalText }],
201
+ fragments: [{ kind: "text", text: doneText }],
200
202
  streaming: streamingState("done", at),
201
203
  completed_at: at,
202
204
  });
203
- options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
205
+ options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}${extendsShown ? "" : " replaced_in_place=true"}`);
204
206
  return messageId;
205
207
  },
206
208
  fail,
@@ -2552,10 +2552,39 @@ export async function startOpenclawClawlingGateway(params) {
2552
2552
  const senderNickName = isBareId(turn.senderNickName, turn.senderId)
2553
2553
  ? (await lookup(turn.senderId)) ?? turn.senderNickName
2554
2554
  : turn.senderNickName;
2555
+ // The wire carries no sender type, so a batch message from another agent
2556
+ // (Hermes, a second OpenClaw…) would read as a person. users/<id>.md holds
2557
+ // the backend's user type; this agent is always an agent.
2558
+ const typeCache = new Map();
2559
+ const lookupProfileType = async (userId) => {
2560
+ if (userId === account.userId)
2561
+ return "agent";
2562
+ if (!typeCache.has(userId)) {
2563
+ let profileType = null;
2564
+ try {
2565
+ const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
2566
+ profileType = userFile.exists && userFile.metadata.profile_type ? userFile.metadata.profile_type : null;
2567
+ }
2568
+ catch {
2569
+ profileType = null;
2570
+ }
2571
+ typeCache.set(userId, profileType);
2572
+ }
2573
+ return typeCache.get(userId) ?? null;
2574
+ };
2555
2575
  const groupMessages = turn.groupMessages
2556
- ? await Promise.all(turn.groupMessages.map(async (message) => isBareId(message.senderName, message.senderId)
2557
- ? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
2558
- : message))
2576
+ ? await Promise.all(turn.groupMessages.map(async (message) => {
2577
+ const named = isBareId(message.senderName, message.senderId)
2578
+ ? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
2579
+ : message;
2580
+ const profileType = await lookupProfileType(message.senderId);
2581
+ if (!profileType || profileType === named.senderProfileType)
2582
+ return named;
2583
+ const senderRelation = profileType === "agent" && named.senderRelation === "peer_user"
2584
+ ? "peer_agent"
2585
+ : named.senderRelation;
2586
+ return { ...named, senderProfileType: profileType, senderRelation };
2587
+ }))
2559
2588
  : turn.groupMessages;
2560
2589
  return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
2561
2590
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.10.7-2",
3
+ "version": "2026.10.8-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -293,10 +293,17 @@ export async function pullGroupMetadata(params: {
293
293
  targetType: "user",
294
294
  targetId: mapped.userId,
295
295
  });
296
- if (existing.exists) continue;
296
+ // A cached file is kept as is, except that one without profile_type
297
+ // (written before types were recorded) gets just that field filled in:
298
+ // without it the prompt lists a peer agent as a person.
299
+ if (existing.exists && existing.metadata.profile_type) continue;
297
300
  if (!getUser) throw new Error("ClawChat participant metadata pull requires getUserProfile");
298
301
  const profile = await getUser(mapped.userId);
299
- const metadata = userMetadataFromProfile(profile, mapped.userId);
302
+ const fetched = userMetadataFromProfile(profile, mapped.userId);
303
+ if (existing.exists && !fetched.profile_type) continue;
304
+ const metadata = existing.exists
305
+ ? { ...existing.metadata, profile_type: fetched.profile_type! }
306
+ : fetched;
300
307
  await writeClawChatMetadata(
301
308
  params.memoryRoot,
302
309
  { targetType: "user", targetId: mapped.userId },
@@ -37,7 +37,7 @@ import {
37
37
  import { isClawChatNoopResponseText } from "./profile-prompt.ts";
38
38
  import type { ClawChatStore } from "./storage.ts";
39
39
  import { consumeTerminalClawChatSend } from "./terminal-send.ts";
40
- import { createReplyStream, type ReplyStream } from "./reply-stream.ts";
40
+ import { createReplyStream, streamableText, type ReplyStream } from "./reply-stream.ts";
41
41
  import { openclawLlmContextDebug } from "./llm-context-debug.ts";
42
42
 
43
43
  export interface ReplyDispatcherOptions {
@@ -112,6 +112,8 @@ type ClawChatReplyOptions = TypedReplyDispatcherResult["replyOptions"] &
112
112
  detailMode?: "explain" | "raw";
113
113
  }) => void | Promise<void>;
114
114
  onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
115
+ /** Newer hosts: a new assistant message starts (after a tool call or thinking block). */
116
+ onAssistantMessageStart?: () => void | Promise<void>;
115
117
  onItemEvent?: (payload: Record<string, unknown>) => void | Promise<void>;
116
118
  onPlanUpdate?: (payload: Record<string, unknown>) => void | Promise<void>;
117
119
  onCommandOutput?: (payload: Record<string, unknown>) => void | Promise<void>;
@@ -514,7 +516,10 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
514
516
  // send, or an idle run with nothing delivered fails it. Host partials are snapshots of
515
517
  // the current assistant message that may be rewritten, so a snapshot that no
516
518
  // longer extends the preview fails the old stream and starts a new one.
517
- const streamEnabled = account.streamReplies === true;
519
+ // Direct chats only, same as Hermes. In a group the server's merged stream
520
+ // copy carries no @, and a receiver that dedupes by message_id keeps
521
+ // whichever copy lands first, so an @-only agent would never wake up.
522
+ const streamEnabled = account.streamReplies === true && !isGroupTarget;
518
523
  let replyStream: ReplyStream | null = null;
519
524
  const failStream = (reason: string) => {
520
525
  replyStream?.fail(reason);
@@ -525,9 +530,18 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
525
530
  replyStream = null;
526
531
  return id;
527
532
  };
533
+ // Latest host snapshot of the current assistant message, including any tail
534
+ // the add throttle held back, so a boundary can close the preview whole.
535
+ let streamPartialText = "";
536
+ // Texts already delivered as their own message at a message boundary; a
537
+ // final that only repeats one of them is not sent again.
538
+ const finalizedPreviewTexts = new Set<string>();
528
539
  const onStreamPartial = (text: string) => {
529
540
  if (runDone || terminalReplySuppressed) return;
530
- if (replyStream?.diverges(text)) failStream("superseded");
541
+ // A snapshot that no longer extends the preview without a message
542
+ // boundary is a host rewrite: the stream stops growing and the final
543
+ // replaces it in place (same id). It is never retracted.
544
+ streamPartialText = text;
531
545
  replyStream ??= createReplyStream({
532
546
  client,
533
547
  chatId: target.chatId,
@@ -725,6 +739,37 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
725
739
  });
726
740
  };
727
741
 
742
+ // ----- Stream message boundary -----------------------------------------
743
+ //
744
+ // A new assistant message (after a tool call or a thinking block) means the
745
+ // previewed one is complete. Close it as its own message — `message.done` +
746
+ // `message.reply` under the same id — instead of letting the next snapshot
747
+ // supersede it, so text the recipient already saw stays. A preview still
748
+ // held back (never shown) is just dropped.
749
+ const finalizeStreamAtBoundary = async (reason: string): Promise<void> => {
750
+ if (!replyStream) return;
751
+ if (!replyStream.isOpen()) {
752
+ replyStream = null;
753
+ streamPartialText = "";
754
+ return;
755
+ }
756
+ const decision = streamableText(streamPartialText);
757
+ const text = decision.kind === "ok"
758
+ ? decision.text
759
+ : decision.kind === "poisoned"
760
+ ? streamPartialText
761
+ : replyStream.shownText();
762
+ streamPartialText = "";
763
+ log?.info?.(
764
+ `[${account.accountId}] clawchat-plugin-openclaw stream boundary reason=${reason} text_len=${text.length} to=${target.chatId}`,
765
+ );
766
+ const result = await sendStatic(text, [], [], { recordMessage: true });
767
+ if (result) finalizedPreviewTexts.add(text.trim());
768
+ // Whatever sendStatic did (sent, suppressed as no-reply, skipped after a
769
+ // terminal tool send), the stream is closed now.
770
+ replyStream = null;
771
+ };
772
+
728
773
  // ----- Static send ------------------------------------------------------
729
774
 
730
775
  const sendStatic = async (
@@ -967,6 +1012,15 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
967
1012
  failStream("no-reply");
968
1013
  return;
969
1014
  }
1015
+ if (
1016
+ !replyStream?.isOpen() &&
1017
+ finalizedPreviewTexts.has(finalText.trim()) &&
1018
+ !richFragment &&
1019
+ finalUrls.length === 0
1020
+ ) {
1021
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
1022
+ return;
1023
+ }
970
1024
  const mediaFragments = await uploadMediaUrls(finalUrls);
971
1025
  const result = await sendStatic(
972
1026
  finalText,
@@ -995,13 +1049,26 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
995
1049
  if (finalDeliverySeen) return;
996
1050
  const fallbackText = bufferedOutputText.trim();
997
1051
  const fallbackUrls = bufferedOutputUrls.slice();
998
- if (!fallbackText && fallbackUrls.length === 0) return;
1052
+ if (!fallbackText && fallbackUrls.length === 0) {
1053
+ // No final came, but a preview was shown: keep it as the reply
1054
+ // rather than retracting text the recipient already read.
1055
+ if (replyStream?.isOpen()) await finalizeStreamAtBoundary("idle");
1056
+ return;
1057
+ }
999
1058
  const mediaFragments = await uploadMediaUrls(fallbackUrls);
1000
1059
  const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
1001
1060
  if (result?.messageId) recordThinkingIfLinked(result.messageId);
1002
1061
  } finally {
1003
- // A preview nothing materialized must not linger as an open stream.
1004
- failStream("undelivered");
1062
+ // Safety net: nothing above claimed or closed an open stream (e.g. the
1063
+ // finalize send threw). It must not linger open, and text already
1064
+ // shown must not vanish: close it with what was shown (the server
1065
+ // materializes that from message.done). Only a preview whose snapshot
1066
+ // turned out to be a no-reply is retracted.
1067
+ if (replyStream?.isOpen()) {
1068
+ if (streamableText(streamPartialText).kind === "poisoned") failStream("no-reply");
1069
+ else claimStreamMessageId(replyStream.shownText());
1070
+ }
1071
+ replyStream = null;
1005
1072
  }
1006
1073
  },
1007
1074
  onCleanup: () => {
@@ -1035,12 +1102,19 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
1035
1102
  if (trimmed) reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
1036
1103
  }
1037
1104
  : undefined,
1038
- onToolStart: splitFullOutput
1105
+ onToolStart: splitFullOutput || streamEnabled
1039
1106
  ? async (payload) => {
1107
+ if (streamEnabled) await finalizeStreamAtBoundary("tool-start");
1108
+ if (!splitFullOutput) return;
1040
1109
  if (consumeTerminalSend("tool-start")) return;
1041
1110
  await emitProcessSegment(formatToolStartSummary(payload));
1042
1111
  }
1043
1112
  : undefined,
1113
+ onAssistantMessageStart: streamEnabled
1114
+ ? async () => {
1115
+ await finalizeStreamAtBoundary("assistant-message-start");
1116
+ }
1117
+ : undefined,
1044
1118
  onToolResult: splitFullOutput
1045
1119
  ? async (payload: ReplyPayload) => {
1046
1120
  if (consumeTerminalSend("tool-result")) return;
@@ -11,12 +11,13 @@
11
11
  * and the server materializes its merged reply from the stream. `finish`
12
12
  * therefore takes the final text, sends whatever tail of it the throttle held
13
13
  * back as one last add, and closes with the whole text. A final that does not
14
- * extend what was shown cannot be appended (§8.2) — the stream is failed and
15
- * the final goes out under its own id.
14
+ * extend what was shown cannot be appended (§8.2): `message.done` then closes
15
+ * with the shown text and the final reply, under the same id, replaces it in
16
+ * place, so text the recipient already saw never vanishes.
16
17
  *
17
- * or, when the reply turns out not to be sendable (no-reply token, run
18
- * aborted, nothing delivered): message.created(M) → … → message.failed(M),
19
- * which aborts the stream without materializing anything.
18
+ * or, when the preview must not stay (a no-reply, or the reply went out
19
+ * through a ClawChat tool instead): message.created(M) → … → message.failed(M),
20
+ * which retracts the preview — the recipient keeps nothing of it.
20
21
  *
21
22
  * Streaming frames get no ack (§8.5) and no server-side mention repair, so the
22
23
  * stream only ever carries plain text; mentions, media and rich fragments ride
@@ -94,9 +95,10 @@ export interface ReplyStream {
94
95
  * Close the stream with the final reply text: an unthrottled last
95
96
  * `message.add` for any tail not yet shown, then `message.done` carrying
96
97
  * `finalText`. Returns the message_id for the final materialized message to
97
- * reuse. Returns null — the final then uses a fresh id — when no stream is
98
- * open, or when `finalText` does not extend the shown text (or is a no-reply),
99
- * in which case the stream is failed instead.
98
+ * reuse. When `finalText` does not extend the shown text, `message.done`
99
+ * carries the shown text and the final, reusing the id, replaces it in place.
100
+ * Returns null — the final then uses a fresh id — when no stream is open, or
101
+ * when `finalText` is a no-reply, in which case the stream is failed.
100
102
  */
101
103
  finish(finalText: string): string | null;
102
104
  /** Abort an open stream with `message.failed`. No-op when nothing is open. */
@@ -220,23 +222,26 @@ export function createReplyStream(options: ReplyStreamOptions): ReplyStream {
220
222
  return null;
221
223
  }
222
224
  // Trailing whitespace a partial carried may be trimmed off the final.
223
- if (!finalText.startsWith(sent.trimEnd())) {
224
- fail("superseded");
225
- return null;
226
- }
227
- if (finalText.length > sent.length && finalText.startsWith(sent)) {
225
+ const extendsShown = finalText.startsWith(sent.trimEnd());
226
+ if (extendsShown && finalText.length > sent.length && finalText.startsWith(sent)) {
228
227
  // Past the throttle: this is the last frame before done.
229
228
  sendAdd(finalText, now());
230
229
  }
230
+ // A final that rewrites the preview cannot be appended (§8.2), but the
231
+ // shown text must not vanish: close the stream with what was shown and
232
+ // let the final reply under this same id replace it in place.
233
+ const doneText = extendsShown ? finalText : sent;
231
234
  closed = true;
232
235
  const at = now();
233
236
  emit(EVENT.MESSAGE_DONE, {
234
237
  message_id: messageId,
235
- fragments: [{ kind: "text", text: finalText }],
238
+ fragments: [{ kind: "text", text: doneText }],
236
239
  streaming: streamingState("done", at),
237
240
  completed_at: at,
238
241
  });
239
- options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
242
+ options.log?.info?.(
243
+ `${prefix} stream done msg=${messageId} text_len=${finalText.length}${extendsShown ? "" : " replaced_in_place=true"}`,
244
+ );
240
245
  return messageId;
241
246
  },
242
247
  fail,
package/src/runtime.ts CHANGED
@@ -3048,12 +3048,36 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3048
3048
  const senderNickName = isBareId(turn.senderNickName, turn.senderId)
3049
3049
  ? (await lookup(turn.senderId)) ?? turn.senderNickName
3050
3050
  : turn.senderNickName;
3051
+ // The wire carries no sender type, so a batch message from another agent
3052
+ // (Hermes, a second OpenClaw…) would read as a person. users/<id>.md holds
3053
+ // the backend's user type; this agent is always an agent.
3054
+ const typeCache = new Map<string, string | null>();
3055
+ const lookupProfileType = async (userId: string): Promise<string | null> => {
3056
+ if (userId === account.userId) return "agent";
3057
+ if (!typeCache.has(userId)) {
3058
+ let profileType: string | null = null;
3059
+ try {
3060
+ const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
3061
+ profileType = userFile.exists && userFile.metadata.profile_type ? userFile.metadata.profile_type : null;
3062
+ } catch {
3063
+ profileType = null;
3064
+ }
3065
+ typeCache.set(userId, profileType);
3066
+ }
3067
+ return typeCache.get(userId) ?? null;
3068
+ };
3051
3069
  const groupMessages = turn.groupMessages
3052
- ? await Promise.all(turn.groupMessages.map(async (message) =>
3053
- isBareId(message.senderName, message.senderId)
3070
+ ? await Promise.all(turn.groupMessages.map(async (message) => {
3071
+ const named = isBareId(message.senderName, message.senderId)
3054
3072
  ? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
3055
- : message
3056
- ))
3073
+ : message;
3074
+ const profileType = await lookupProfileType(message.senderId);
3075
+ if (!profileType || profileType === named.senderProfileType) return named;
3076
+ const senderRelation = profileType === "agent" && named.senderRelation === "peer_user"
3077
+ ? "peer_agent" as const
3078
+ : named.senderRelation;
3079
+ return { ...named, senderProfileType: profileType, senderRelation };
3080
+ }))
3057
3081
  : turn.groupMessages;
3058
3082
  return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
3059
3083
  };