Compare commits

...

3 Commits

Author SHA1 Message Date
Vincent Koc
28a5b16bbe Telegram: clear only transient preview after fallback cleanup 2026-03-12 03:43:12 -04:00
Timo
48f2924d30 test(telegram): add orphaned preview cleanup coverage
Add 5 test cases for the orphaned preview deletion path in fallback
final send:
- deletes orphaned preview after successful fallback send
- preserves preview when fallback send fails
- no-op when no preview message exists
- logs error but does not throw when cleanup fails
- handles preview created by stop() during fallback

Addresses Greptile review feedback on #39455.
2026-03-12 03:41:45 -04:00
eveiljuice
4f7bd48458 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
2026-03-12 03:38:23 -04:00
3 changed files with 104 additions and 7 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,26 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
);
}
const shouldCleanupTransientPreview =
params.activePreviewLifecycleByLane[laneName] === "transient";
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 &&
shouldCleanupTransientPreview &&
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,
};
}
@ -434,7 +436,6 @@ describe("createLaneTextDeliverer", () => {
it("retains preview on ambiguous API error during final", async () => {
const harness = createHarness({ answerMessageId: 999 });
// Plain Error with no error_code → ambiguous, prefer incomplete over duplicate
harness.editPreview.mockRejectedValue(new Error("500: Internal Server Error"));
const result = await harness.deliverLaneText({
@ -473,6 +474,78 @@ 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("does not delete orphaned preview when fallback send fails", async () => {
const harness = createHarness({ answerMessageId: 555 });
harness.sendPayload.mockResolvedValue(false);
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final with image",
payload: { text: "Final with image", mediaUrl: "file:///tmp/photo.png" },
infoKind: "final",
});
expect(result).toBe("skipped");
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
expect(harness.deletePreviewMessage).not.toHaveBeenCalled();
});
it("logs error but does not throw when orphaned preview cleanup fails", async () => {
const harness = createHarness({ answerMessageId: 555 });
harness.deletePreviewMessage.mockRejectedValue(new Error("404: message not found"));
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final with image",
payload: { text: "Final with image", mediaUrl: "file:///tmp/photo.png" },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(555);
expect(harness.log).toHaveBeenCalledWith(
expect.stringContaining("orphaned preview cleanup failed"),
);
});
it("deletes orphaned preview created by stop() during fallback", async () => {
const harness = createHarness({ answerMessageIdAfterStop: 888 });
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final with image",
payload: { text: "Final with image", mediaUrl: "file:///tmp/photo.png" },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(888);
});
it("keeps the active preview when an archived final edit target is missing", async () => {
const harness = createHarness({ answerMessageId: 999 });
harness.archivedAnswerPreviews.push({
@ -557,14 +630,12 @@ describe("createLaneTextDeliverer", () => {
});
it("retains when sendMayHaveLanded is true and a prior preview was visible", async () => {
// Stream has a messageId (visible preview) but loses it after stop
const stream = createTestDraftStream({ messageId: 999 });
stream.sendMayHaveLanded.mockReturnValue(true);
const harness = createHarness({
answerStream: stream,
answerHasStreamedMessage: true,
});
// Simulate messageId lost after stop (e.g. forceNewMessage or timeout)
harness.stopDraftLane.mockImplementation(async (lane: DraftLaneState) => {
stream.setMessageId(undefined);
await lane.stream?.stop();