From 4f7bd4845847d2d20d47150c8fb22a6431b61d45 Mon Sep 17 00:00:00 2001 From: eveiljuice Date: Sun, 8 Mar 2026 04:27:11 +0000 Subject: [PATCH] fix(telegram): delete orphaned preview message after fallback send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the answer lane in lane-delivery.ts fails to finalize a preview via edit and falls back to sendPayload(), the previously-flushed preview message was left visible. The user sees both the preview and the final message — the classic 'message appears twice, one disappears' behavior. The fallback send path now captures the preview message ID before stopping the draft lane and deletes it after successful delivery. Closes #38365 Related: #37702, #38434, #33308, #33453 --- CHANGELOG.md | 1 + src/telegram/lane-delivery-text-deliverer.ts | 19 +++++++++++ src/telegram/lane-delivery.test.ts | 33 +++++++++++++++++--- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee68ed4948d..b7e70bdd0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one. +- Telegram/DM fallback preview cleanup: delete orphaned answer previews after fallback final sends and clear the consumed preview state so later assistant-message boundaries do not archive deleted preview ids or force duplicate fallback sends. (#39455) Thanks @eveiljuice. - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. diff --git a/src/telegram/lane-delivery-text-deliverer.ts b/src/telegram/lane-delivery-text-deliverer.ts index 000087cc692..5fa5169ebb8 100644 --- a/src/telegram/lane-delivery-text-deliverer.ts +++ b/src/telegram/lane-delivery-text-deliverer.ts @@ -171,6 +171,13 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { params.activePreviewLifecycleByLane[laneName] = "complete"; params.retainPreviewOnCleanupByLane[laneName] = true; }; + const clearActivePreviewState = (laneName: LaneName, lane: DraftLaneState) => { + lane.stream?.forceNewMessage?.(); + lane.lastPartialText = ""; + lane.hasStreamedMessage = false; + params.activePreviewLifecycleByLane[laneName] = "transient"; + params.retainPreviewOnCleanupByLane[laneName] = false; + }; const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft"; const canMaterializeDraftFinal = ( lane: DraftLaneState, @@ -529,8 +536,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { `telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`, ); } + const previewMessageIdBeforeFallback = lane.stream?.messageId(); await params.stopDraftLane(lane); + const previewMessageIdAfterStop = previewMessageIdBeforeFallback ?? lane.stream?.messageId(); const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); + if (delivered && typeof previewMessageIdAfterStop === "number") { + try { + await params.deletePreviewMessage(previewMessageIdAfterStop); + clearActivePreviewState(laneName, lane); + } catch (err) { + params.log( + `telegram: ${laneName} fallback send orphaned preview cleanup failed (${previewMessageIdAfterStop}): ${String(err)}`, + ); + } + } return delivered ? "sent" : "skipped"; } diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts index 3a165147d84..e3dcc320fd9 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/src/telegram/lane-delivery.test.ts @@ -42,8 +42,8 @@ function createHarness(params?: { const deletePreviewMessage = vi.fn().mockResolvedValue(undefined); const log = vi.fn(); const markDelivered = vi.fn(); - const activePreviewLifecycleByLane = { answer: "transient", reasoning: "transient" } as const; - const retainPreviewOnCleanupByLane = { answer: false, reasoning: false } as const; + const activePreviewLifecycleByLane = { answer: "transient", reasoning: "transient" }; + const retainPreviewOnCleanupByLane = { answer: false, reasoning: false }; const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string; @@ -53,8 +53,8 @@ function createHarness(params?: { const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - activePreviewLifecycleByLane: { ...activePreviewLifecycleByLane }, - retainPreviewOnCleanupByLane: { ...retainPreviewOnCleanupByLane }, + activePreviewLifecycleByLane, + retainPreviewOnCleanupByLane, draftMaxChars: params?.draftMaxChars ?? 4_096, applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }), sendPayload, @@ -81,6 +81,8 @@ function createHarness(params?: { log, markDelivered, archivedAnswerPreviews, + activePreviewLifecycleByLane, + retainPreviewOnCleanupByLane, }; } @@ -473,6 +475,29 @@ describe("createLaneTextDeliverer", () => { expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); }); + it("clears active preview state after successful fallback orphan cleanup", async () => { + const harness = createHarness({ + answerMessageId: 5555, + answerHasStreamedMessage: true, + answerLastPartialText: "Partial streaming...", + }); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Final with media", + payload: { text: "Final with media", mediaUrl: "file:///tmp/example.png" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); + expect(harness.answer.stream?.forceNewMessage).toHaveBeenCalledTimes(1); + expect(harness.lanes.answer.hasStreamedMessage).toBe(false); + expect(harness.lanes.answer.lastPartialText).toBe(""); + expect(harness.activePreviewLifecycleByLane.answer).toBe("transient"); + expect(harness.retainPreviewOnCleanupByLane.answer).toBe(false); + }); + it("keeps the active preview when an archived final edit target is missing", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.archivedAnswerPreviews.push({