From 2b38345c8acc81449ce7bd7bf41c19d10aff01d3 Mon Sep 17 00:00:00 2001 From: jack-stormentswe Date: Sun, 3 May 2026 16:23:31 +0200 Subject: [PATCH] fix(telegram): force fresh final after visible intermediate output (#76529) --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 38 +++++++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 3 ++ .../src/lane-delivery-text-deliverer.ts | 25 ++++++++++-- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6642f493651..146ad868485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai - Plugins/providers: preserve scoped cold-load fallback for enabled external manifest-contract capability providers missing from the startup registry, so providers such as Fish Audio can resolve on request without requiring `activation.onStartup` for correctness. (#76536) Thanks @Conan-Scott. - Gateway/update: carry `continuationMessage` from `update.run` into successful restart sentinels so session-scoped self-updates can resume one follow-up turn after the Gateway restarts. Refs #71178. (#74362) Thanks @100menotu001, @HeilbronAILabs, and @artnking. - Agents/fallback: suppress duplicate current-turn user-message transcript writes after embedded fallback retries while still sending the retry prompt to the model. (#63696) Thanks @dashhuang. +- Channels/Telegram: force a fresh final message when a visible non-preview bubble (tool/block/error) was delivered after the active answer preview, so multi-step assistant replies no longer end up with the final answer above intermediate output. Fixes #76529. Thanks @jack-stormentswe. ## 2026.5.2 diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 24c3c9041a5..ceda5e45db2 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -3213,6 +3213,44 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).not.toHaveBeenCalled(); }); + // #76529: when a visible non-final message is delivered after the answer + // preview is already on screen, finalizing the preview by edit puts the + // final answer above the intermediate output. Force a fresh send instead. + it("sends a fresh final after a visible block bubble pushes the preview up (#76529)", async () => { + // Preview was already on screen before the block bubble was sent. + const draftStream = createTestDraftStream({ + messageId: 999, + visibleSinceMs: Date.now() - 1_000, + }); + createTelegramDraftStream.mockReturnValue(draftStream); + editMessageTelegram.mockResolvedValue({ ok: true }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Checked maxDBdays..." }); + await dispatcherOptions.deliver( + { text: "Changed maxDBdays from 91 → 14" }, + { kind: "block" }, + ); + await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext() }); + + // Block + fresh final both went through deliverReplies; preview was not + // edited in place and the stale preview was cleared. + expect(deliverReplies).toHaveBeenCalledTimes(2); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deliverReplies).toHaveBeenLastCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "Done" })], + }), + ); + expect(draftStream.clear).toHaveBeenCalled(); + }); + it("cleans up preview even when fallback delivery throws (double failure)", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 09a78447f54..0667a2f98a7 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -729,6 +729,7 @@ export const dispatchTelegramMessage = async ({ } return { ...payload, replyToId: implicitQuoteReplyTargetId }; }; + let lastVisibleNonPreviewDeliveryAtMs: number | undefined; const sendPayload = async (payload: ReplyPayload) => { if (isDispatchSuperseded()) { return false; @@ -742,6 +743,7 @@ export const dispatchTelegramMessage = async ({ }); if (result.delivered) { deliveryState.markDelivered(); + lastVisibleNonPreviewDeliveryAtMs = Date.now(); } return result.delivered; }; @@ -794,6 +796,7 @@ export const dispatchTelegramMessage = async ({ markDelivered: () => { deliveryState.markDelivered(); }, + getLastVisibleNonPreviewDeliveryAtMs: () => lastVisibleNonPreviewDeliveryAtMs, }); if (isDmTopic) { diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index 0930e738cfd..bd234459c2e 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -95,6 +95,10 @@ type CreateLaneTextDelivererParams = { log: (message: string) => void; markDelivered: () => void; now?: () => number; + // Force fresh final when a visible non-preview message has been delivered + // since the active preview was created, even if the preview is younger + // than the long-lived threshold (#76529). + getLastVisibleNonPreviewDeliveryAtMs?: () => number | undefined; }; type DeliverLaneTextParams = { @@ -204,10 +208,25 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { params.retainPreviewOnCleanupByLane[laneName] = true; }; const isMessagePreviewLane = (lane: DraftLaneState) => lane.stream != null; - const shouldUseFreshFinalForLane = (lane: DraftLaneState) => - isMessagePreviewLane(lane) && isLongLivedPreview(lane.stream?.visibleSinceMs?.(), readNow()); + const wasVisiblyOverwrittenSince = (visibleSinceMs: number | undefined): boolean => { + if (typeof visibleSinceMs !== "number") { + return false; + } + const lastNonPreviewAt = params.getLastVisibleNonPreviewDeliveryAtMs?.(); + return typeof lastNonPreviewAt === "number" && lastNonPreviewAt > visibleSinceMs; + }; + const shouldUseFreshFinalForLane = (lane: DraftLaneState) => { + if (!isMessagePreviewLane(lane)) { + return false; + } + const visibleSinceMs = lane.stream?.visibleSinceMs?.(); + return ( + isLongLivedPreview(visibleSinceMs, readNow()) || wasVisiblyOverwrittenSince(visibleSinceMs) + ); + }; const shouldUseFreshFinalForPreview = (lane: DraftLaneState, visibleSinceMs?: number) => - isMessagePreviewLane(lane) && isLongLivedPreview(visibleSinceMs, readNow()); + isMessagePreviewLane(lane) && + (isLongLivedPreview(visibleSinceMs, readNow()) || wasVisiblyOverwrittenSince(visibleSinceMs)); const clearActivePreviewAfterFreshFinal = async (lane: DraftLaneState, laneName: LaneName) => { try { await lane.stream?.clear();