mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 15:24:58 +00:00
fix: stabilize telegram draft boundary previews (#33842) (thanks @ngutman)
This commit is contained in:
@@ -214,6 +214,7 @@ export const dispatchTelegramMessage = async ({
|
||||
archivedAnswerPreviews.push({
|
||||
messageId: preview.messageId,
|
||||
textSnapshot: preview.textSnapshot,
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
@@ -239,7 +240,15 @@ export const dispatchTelegramMessage = async ({
|
||||
const reasoningLane = lanes.reasoning;
|
||||
let splitReasoningOnNextStream = false;
|
||||
let skipNextAnswerMessageStartRotation = false;
|
||||
let draftLaneEventQueue = Promise.resolve();
|
||||
const reasoningStepState = createTelegramReasoningStepState();
|
||||
const enqueueDraftLaneEvent = (task: () => Promise<void>): Promise<void> => {
|
||||
const next = draftLaneEventQueue.then(task);
|
||||
draftLaneEventQueue = next.catch((err) => {
|
||||
logVerbose(`telegram: draft lane callback failed: ${String(err)}`);
|
||||
});
|
||||
return draftLaneEventQueue;
|
||||
};
|
||||
type SplitLaneSegment = { lane: LaneName; text: string };
|
||||
type SplitLaneSegmentsResult = {
|
||||
segments: SplitLaneSegment[];
|
||||
@@ -265,17 +274,18 @@ export const dispatchTelegramMessage = async ({
|
||||
lane.lastPartialText = "";
|
||||
lane.hasStreamedMessage = false;
|
||||
};
|
||||
const rotateAnswerLaneForNewAssistantMessage = () => {
|
||||
const rotateAnswerLaneForNewAssistantMessage = async () => {
|
||||
let didForceNewMessage = false;
|
||||
if (answerLane.hasStreamedMessage) {
|
||||
const previewMessageId = answerLane.stream?.messageId();
|
||||
// Only archive previews that still need a matching final text update.
|
||||
// Once a preview has already been finalized, archiving it here causes
|
||||
// cleanup to delete a user-visible final message on later media-only turns.
|
||||
// Materialize the current streamed draft into a permanent message
|
||||
// so it remains visible across tool boundaries.
|
||||
const materializedId = await answerLane.stream?.materialize?.();
|
||||
const previewMessageId = materializedId ?? answerLane.stream?.messageId();
|
||||
if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) {
|
||||
archivedAnswerPreviews.push({
|
||||
messageId: previewMessageId,
|
||||
textSnapshot: answerLane.lastPartialText,
|
||||
deleteIfUnused: false,
|
||||
});
|
||||
}
|
||||
answerLane.stream?.forceNewMessage();
|
||||
@@ -311,14 +321,14 @@ export const dispatchTelegramMessage = async ({
|
||||
lane.lastPartialText = text;
|
||||
laneStream.update(text);
|
||||
};
|
||||
const ingestDraftLaneSegments = (text: string | undefined) => {
|
||||
const ingestDraftLaneSegments = async (text: string | undefined) => {
|
||||
const split = splitTextIntoLaneSegments(text);
|
||||
const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer");
|
||||
if (hasAnswerSegment && finalizedPreviewByLane.answer) {
|
||||
// Some providers can emit the first partial of a new assistant message before
|
||||
// onAssistantMessageStart() arrives. Rotate preemptively so we do not edit
|
||||
// the previously finalized preview message with the next message's text.
|
||||
skipNextAnswerMessageStartRotation = rotateAnswerLaneForNewAssistantMessage();
|
||||
skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage();
|
||||
}
|
||||
for (const segment of split.segments) {
|
||||
if (segment.lane === "reasoning") {
|
||||
@@ -501,6 +511,11 @@ export const dispatchTelegramMessage = async ({
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
// Assistant callbacks are fire-and-forget; ensure queued boundary
|
||||
// rotations/partials are applied before final delivery mapping.
|
||||
await enqueueDraftLaneEvent(async () => {});
|
||||
}
|
||||
const previewButtons = (
|
||||
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
|
||||
)?.buttons;
|
||||
@@ -610,42 +625,48 @@ export const dispatchTelegramMessage = async ({
|
||||
disableBlockStreaming,
|
||||
onPartialReply:
|
||||
answerLane.stream || reasoningLane.stream
|
||||
? (payload) => ingestDraftLaneSegments(payload.text)
|
||||
? (payload) =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
await ingestDraftLaneSegments(payload.text);
|
||||
})
|
||||
: undefined,
|
||||
onReasoningStream: reasoningLane.stream
|
||||
? (payload) => {
|
||||
// Split between reasoning blocks only when the next reasoning
|
||||
// stream starts. Splitting at reasoning-end can orphan the active
|
||||
// preview and cause duplicate reasoning sends on reasoning final.
|
||||
if (splitReasoningOnNextStream) {
|
||||
reasoningLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(reasoningLane);
|
||||
splitReasoningOnNextStream = false;
|
||||
}
|
||||
ingestDraftLaneSegments(payload.text);
|
||||
}
|
||||
? (payload) =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
// Split between reasoning blocks only when the next reasoning
|
||||
// stream starts. Splitting at reasoning-end can orphan the active
|
||||
// preview and cause duplicate reasoning sends on reasoning final.
|
||||
if (splitReasoningOnNextStream) {
|
||||
reasoningLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(reasoningLane);
|
||||
splitReasoningOnNextStream = false;
|
||||
}
|
||||
await ingestDraftLaneSegments(payload.text);
|
||||
})
|
||||
: undefined,
|
||||
onAssistantMessageStart: answerLane.stream
|
||||
? async () => {
|
||||
reasoningStepState.resetForNextStep();
|
||||
if (skipNextAnswerMessageStartRotation) {
|
||||
skipNextAnswerMessageStartRotation = false;
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
reasoningStepState.resetForNextStep();
|
||||
if (skipNextAnswerMessageStartRotation) {
|
||||
skipNextAnswerMessageStartRotation = false;
|
||||
finalizedPreviewByLane.answer = false;
|
||||
return;
|
||||
}
|
||||
await rotateAnswerLaneForNewAssistantMessage();
|
||||
// Message-start is an explicit assistant-message boundary.
|
||||
// Even when no forceNewMessage happened (e.g. prior answer had no
|
||||
// streamed partials), the next partial belongs to a fresh lifecycle
|
||||
// and must not trigger late pre-rotation mid-message.
|
||||
finalizedPreviewByLane.answer = false;
|
||||
return;
|
||||
}
|
||||
rotateAnswerLaneForNewAssistantMessage();
|
||||
// Message-start is an explicit assistant-message boundary.
|
||||
// Even when no forceNewMessage happened (e.g. prior answer had no
|
||||
// streamed partials), the next partial belongs to a fresh lifecycle
|
||||
// and must not trigger late pre-rotation mid-message.
|
||||
finalizedPreviewByLane.answer = false;
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
onReasoningEnd: reasoningLane.stream
|
||||
? () => {
|
||||
// Split when/if a later reasoning block begins.
|
||||
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
||||
}
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
// Split when/if a later reasoning block begins.
|
||||
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
||||
})
|
||||
: undefined,
|
||||
onToolStart: statusReactionController
|
||||
? async (payload) => {
|
||||
@@ -656,6 +677,9 @@ export const dispatchTelegramMessage = async ({
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
// Upstream assistant callbacks are fire-and-forget; drain queued lane work
|
||||
// before stream cleanup so boundary rotations/materialization complete first.
|
||||
await draftLaneEventQueue;
|
||||
// Must stop() first to flush debounced content before clear() wipes state.
|
||||
const streamCleanupStates = new Map<
|
||||
NonNullable<DraftLaneState["stream"]>,
|
||||
@@ -670,7 +694,17 @@ export const dispatchTelegramMessage = async ({
|
||||
if (!stream) {
|
||||
continue;
|
||||
}
|
||||
const shouldClear = !finalizedPreviewByLane[laneState.laneName];
|
||||
// Don't clear (delete) the stream if: (a) it was finalized, or
|
||||
// (b) the active stream message is itself a boundary-finalized archive.
|
||||
const activePreviewMessageId = stream.messageId();
|
||||
const hasBoundaryFinalizedActivePreview =
|
||||
laneState.laneName === "answer" &&
|
||||
typeof activePreviewMessageId === "number" &&
|
||||
archivedAnswerPreviews.some(
|
||||
(p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId,
|
||||
);
|
||||
const shouldClear =
|
||||
!finalizedPreviewByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview;
|
||||
const existing = streamCleanupStates.get(stream);
|
||||
if (!existing) {
|
||||
streamCleanupStates.set(stream, { shouldClear });
|
||||
@@ -685,6 +719,9 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
}
|
||||
for (const archivedPreview of archivedAnswerPreviews) {
|
||||
if (archivedPreview.deleteIfUnused === false) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await bot.api.deleteMessage(chatId, archivedPreview.messageId);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user