fix(telegram): delete orphaned preview message after fallback send

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
This commit is contained in:
eveiljuice 2026-03-08 04:27:11 +00:00 committed by Vincent Koc
parent 82e3ac21ee
commit 4f7bd48458
3 changed files with 49 additions and 4 deletions

View File

@ -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.

View File

@ -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";
}

View File

@ -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({