@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.
- package/dist/src/clawchat-metadata.js +10 -2
- package/dist/src/reply-dispatcher.js +80 -8
- package/dist/src/reply-stream.js +14 -12
- package/dist/src/runtime.js +32 -3
- package/package.json +1 -1
- package/src/clawchat-metadata.ts +9 -2
- package/src/reply-dispatcher.ts +81 -7
- package/src/reply-stream.ts +20 -15
- package/src/runtime.ts +28 -4
|
@@ -202,12 +202,20 @@ export async function pullGroupMetadata(params) {
|
|
|
202
202
|
targetType: "user",
|
|
203
203
|
targetId: mapped.userId,
|
|
204
204
|
});
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
//
|
|
808
|
-
|
|
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"))
|
package/dist/src/reply-stream.js
CHANGED
|
@@ -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)
|
|
15
|
-
* the final
|
|
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
|
|
18
|
-
*
|
|
19
|
-
* which
|
|
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
|
-
|
|
188
|
-
|
|
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:
|
|
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,
|
package/dist/src/runtime.js
CHANGED
|
@@ -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) =>
|
|
2557
|
-
|
|
2558
|
-
|
|
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
package/src/clawchat-metadata.ts
CHANGED
|
@@ -293,10 +293,17 @@ export async function pullGroupMetadata(params: {
|
|
|
293
293
|
targetType: "user",
|
|
294
294
|
targetId: mapped.userId,
|
|
295
295
|
});
|
|
296
|
-
|
|
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
|
|
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 },
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1004
|
-
|
|
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;
|
package/src/reply-stream.ts
CHANGED
|
@@ -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)
|
|
15
|
-
* the final
|
|
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
|
|
18
|
-
*
|
|
19
|
-
* which
|
|
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.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
224
|
-
|
|
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:
|
|
238
|
+
fragments: [{ kind: "text", text: doneText }],
|
|
236
239
|
streaming: streamingState("done", at),
|
|
237
240
|
completed_at: at,
|
|
238
241
|
});
|
|
239
|
-
options.log?.info?.(
|
|
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
|
};
|