From 38d5183f387fd8d6cbd3e43c6e695a378eaed79b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 09:13:13 +0530 Subject: [PATCH] fix(telegram): unify preview final edit outcomes --- src/telegram/bot-message-dispatch.test.ts | 36 ++++-- src/telegram/bot-message-dispatch.ts | 58 ++++------ src/telegram/lane-delivery-text-deliverer.ts | 97 +++++++++++----- src/telegram/lane-delivery.test.ts | 111 +++++++++++++------ src/telegram/lane-delivery.ts | 1 + 5 files changed, 189 insertions(+), 114 deletions(-) diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 2070e49ff48..09f33ad22fe 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -1921,12 +1921,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext() }); - // The edit was attempted (and may have succeeded server-side). expect(editMessageTelegram).toHaveBeenCalledTimes(1); - // The fix: no fallback sendPayload via deliverReplies for the final text, - // because the editPreview callback swallowed the network timeout. - // deliverReplies should NOT have been called with the "Final answer" text - // (it would only be called if the fallback chain fired). const deliverCalls = deliverReplies.mock.calls; const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( @@ -1936,7 +1931,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(finalTextSentViaDeliverReplies).toBe(false); }); - it("keeps preview on pre-connect error during final edit (lane deliverer treats as delivered)", async () => { + it("falls back to sendPayload on pre-connect error during final edit", async () => { const draftStream = createDraftStream(999); createTelegramDraftStream.mockReturnValue(draftStream); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( @@ -1947,9 +1942,6 @@ describe("dispatchTelegramMessage draft streaming", () => { }, ); deliverReplies.mockResolvedValue({ delivered: true }); - // Pre-connect error: edit never reached Telegram. The dispatch-level - // callback re-throws it, but the lane deliverer catches it and treats - // the final edit failure as delivered to avoid a duplicate message. const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443"); (preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED"; editMessageTelegram.mockRejectedValue(preConnectErr); @@ -1957,7 +1949,31 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext() }); expect(editMessageTelegram).toHaveBeenCalledTimes(1); - // Lane deliverer keeps the preview — no fallback sendPayload. + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(true); + }); + + it("keeps preview when Telegram reports the final edit target missing", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); const deliverCalls = deliverReplies.mock.calls; const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 2f791f3805c..af70bbd9a3f 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -38,8 +38,8 @@ import { createLaneTextDeliverer, type DraftLaneState, type LaneName, + type LanePreviewDisposition, } from "./lane-delivery.js"; -import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; import { createTelegramReasoningStepState, splitTelegramReasoningText, @@ -240,9 +240,9 @@ export const dispatchTelegramMessage = async ({ answer: createDraftLane("answer", canStreamAnswerDraft), reasoning: createDraftLane("reasoning", canStreamReasoningDraft), }; - const finalizedPreviewByLane: Record = { - answer: false, - reasoning: false, + const previewDispositionByLane: Record = { + answer: "transient", + reasoning: "transient", }; const answerLane = lanes.answer; const reasoningLane = lanes.reasoning; @@ -289,7 +289,7 @@ export const dispatchTelegramMessage = async ({ // 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) { + if (typeof previewMessageId === "number" && previewDispositionByLane.answer === "transient") { archivedAnswerPreviews.push({ messageId: previewMessageId, textSnapshot: answerLane.lastPartialText, @@ -302,7 +302,7 @@ export const dispatchTelegramMessage = async ({ resetDraftLaneState(answerLane); if (didForceNewMessage) { // New assistant message boundary: this lane now tracks a fresh preview lifecycle. - finalizedPreviewByLane.answer = false; + previewDispositionByLane.answer = "transient"; } return didForceNewMessage; }; @@ -332,7 +332,7 @@ export const dispatchTelegramMessage = async ({ const ingestDraftLaneSegments = async (text: string | undefined) => { const split = splitTextIntoLaneSegments(text); const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer"); - if (hasAnswerSegment && finalizedPreviewByLane.answer) { + if (hasAnswerSegment && previewDispositionByLane.answer !== "transient") { // 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. @@ -470,7 +470,7 @@ export const dispatchTelegramMessage = async ({ const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - finalizedPreviewByLane, + previewDispositionByLane, draftMaxChars, applyTextToPayload, sendPayload, @@ -479,32 +479,13 @@ export const dispatchTelegramMessage = async ({ await lane.stream?.stop(); }, editPreview: async ({ messageId, text, previewButtons }) => { - try { - await editMessageTelegram(chatId, messageId, text, { - api: bot.api, - cfg, - accountId: route.accountId, - linkPreview: telegramCfg.linkPreview, - buttons: previewButtons, - }); - } catch (err) { - // Post-connect network errors (timeout, connection reset) mean the edit - // may have already landed on Telegram's server. Swallow these to prevent - // the fallback chain from sending a duplicate message via sendPayload. - // Only re-throw pre-connect errors (DNS, connection refused — edit - // definitely never reached Telegram) and API errors (400/500 — Telegram - // explicitly rejected the edit). - if (isSafeToRetrySendError(err)) { - throw err; - } - if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) { - logVerbose( - `telegram: preview edit may have succeeded despite network error; treating as delivered to avoid duplicate (${String(err)})`, - ); - return; - } - throw err; - } + await editMessageTelegram(chatId, messageId, text, { + api: bot.api, + cfg, + accountId: route.accountId, + linkPreview: telegramCfg.linkPreview, + buttons: previewButtons, + }); }, deletePreviewMessage: async (messageId) => { await bot.api.deleteMessage(chatId, messageId); @@ -616,7 +597,7 @@ export const dispatchTelegramMessage = async ({ } if (info.kind === "final") { if (reasoningLane.hasStreamedMessage) { - finalizedPreviewByLane.reasoning = true; + previewDispositionByLane.reasoning = "finalized"; } reasoningStepState.resetForNextStep(); } @@ -694,7 +675,7 @@ export const dispatchTelegramMessage = async ({ reasoningStepState.resetForNextStep(); if (skipNextAnswerMessageStartRotation) { skipNextAnswerMessageStartRotation = false; - finalizedPreviewByLane.answer = false; + previewDispositionByLane.answer = "transient"; return; } await rotateAnswerLaneForNewAssistantMessage(); @@ -702,7 +683,7 @@ export const dispatchTelegramMessage = async ({ // 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; + previewDispositionByLane.answer = "transient"; }) : undefined, onReasoningEnd: reasoningLane.stream @@ -751,7 +732,8 @@ export const dispatchTelegramMessage = async ({ (p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId, ); const shouldClear = - !finalizedPreviewByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview; + previewDispositionByLane[laneState.laneName] === "transient" && + !hasBoundaryFinalizedActivePreview; const existing = streamCleanupStates.get(stream); if (!existing) { streamCleanupStates.set(stream, { shouldClear }); diff --git a/src/telegram/lane-delivery-text-deliverer.ts b/src/telegram/lane-delivery-text-deliverer.ts index f77f38aaf5f..463abb8967f 100644 --- a/src/telegram/lane-delivery-text-deliverer.ts +++ b/src/telegram/lane-delivery-text-deliverer.ts @@ -1,6 +1,7 @@ import type { ReplyPayload } from "../auto-reply/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; +import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; const MESSAGE_NOT_MODIFIED_RE = /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; @@ -47,12 +48,19 @@ export type ArchivedPreview = { deleteIfUnused?: boolean; }; -export type LaneDeliveryResult = "preview-finalized" | "preview-updated" | "sent" | "skipped"; +export type LanePreviewDisposition = "transient" | "retained" | "finalized"; + +export type LaneDeliveryResult = + | "preview-finalized" + | "preview-retained" + | "preview-updated" + | "sent" + | "skipped"; type CreateLaneTextDelivererParams = { lanes: Record; archivedAnswerPreviews: ArchivedPreview[]; - finalizedPreviewByLane: Record; + previewDispositionByLane: Record; draftMaxChars: number; applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload; sendPayload: (payload: ReplyPayload) => Promise; @@ -92,6 +100,8 @@ type TryUpdatePreviewParams = { previewTextSnapshot?: string; }; +type PreviewEditResult = "edited" | "retained" | "fallback"; + type ConsumeArchivedAnswerPreviewParams = { lane: DraftLaneState; text: string; @@ -196,8 +206,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewButtons?: TelegramInlineButtons; updateLaneSnapshot: boolean; lane: DraftLaneState; - treatEditFailureAsDelivered: boolean; - }): Promise => { + finalTextAlreadyLanded: boolean; + }): Promise => { try { await params.editPreview({ laneName: args.laneName, @@ -210,32 +220,52 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { args.lane.lastPartialText = args.text; } params.markDelivered(); - return true; + return "edited"; } catch (err) { if (isMessageNotModifiedError(err)) { params.log( `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, ); params.markDelivered(); - return true; + return "edited"; } - if (args.treatEditFailureAsDelivered) { + if (args.context === "final") { + if (args.finalTextAlreadyLanded) { + params.log( + `telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } + if (isSafeToRetrySendError(err)) { + params.log( + `telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } if (isMissingPreviewMessageError(err)) { params.log( - `telegram: ${args.laneName} preview ${args.context} edit target missing; falling back to standard send (${String(err)})`, + `telegram: ${args.laneName} preview final edit target missing; keeping existing preview without fallback (${String(err)})`, ); - return false; + params.markDelivered(); + return "retained"; + } + if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) { + params.log( + `telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + return "retained"; } params.log( - `telegram: ${args.laneName} preview ${args.context} edit failed; keeping existing preview to avoid duplicate (${String(err)})`, + `telegram: ${args.laneName} preview final edit rejected by Telegram; falling back to standard send (${String(err)})`, ); - params.markDelivered(); - return true; + return "fallback"; } params.log( `telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`, ); - return false; + return "fallback"; } }; @@ -250,8 +280,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, previewMessageId: previewMessageIdOverride, previewTextSnapshot, - }: TryUpdatePreviewParams): Promise => { - const editPreview = (messageId: number, treatEditFailureAsDelivered: boolean) => + }: TryUpdatePreviewParams): Promise => { + const editPreview = (messageId: number, finalTextAlreadyLanded: boolean) => tryEditPreviewMessage({ laneName, messageId, @@ -260,13 +290,13 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewButtons, updateLaneSnapshot, lane, - treatEditFailureAsDelivered, + finalTextAlreadyLanded, }); const finalizePreview = ( previewMessageId: number, - treatEditFailureAsDelivered: boolean, + finalTextAlreadyLanded: boolean, hadPreviewMessage: boolean, - ): boolean | Promise => { + ): PreviewEditResult | Promise => { const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane); const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({ currentPreviewText, @@ -276,12 +306,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { }); if (shouldSkipRegressive) { params.markDelivered(); - return true; + return "edited"; } - return editPreview(previewMessageId, treatEditFailureAsDelivered); + return editPreview(previewMessageId, finalTextAlreadyLanded); }; if (!lane.stream) { - return false; + return "fallback"; } const previewTargetBeforeStop = resolvePreviewTarget({ lane, @@ -300,7 +330,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - return false; + return "fallback"; } return finalizePreview(previewTargetAfterStop.previewMessageId, true, false); } @@ -314,11 +344,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - return false; + return "fallback"; } return finalizePreview( previewTargetAfterStop.previewMessageId, - context === "final", + false, previewTargetAfterStop.hadPreviewMessage, ); }; @@ -346,9 +376,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewMessageId: archivedPreview.messageId, previewTextSnapshot: archivedPreview.textSnapshot, }); - if (finalized) { + if (finalized === "edited") { return "preview-finalized"; } + if (finalized === "retained") { + return "preview-retained"; + } } // Send the replacement message first, then clean up the old preview. // This avoids the visual "disappear then reappear" flash. @@ -393,7 +426,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { return archivedResult; } } - if (canEditViaPreview && !params.finalizedPreviewByLane[laneName]) { + if (canEditViaPreview && params.previewDispositionByLane[laneName] === "transient") { await params.flushDraftLane(lane); if (laneName === "answer") { const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({ @@ -414,7 +447,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { text, }); if (materialized) { - params.finalizedPreviewByLane[laneName] = true; + params.previewDispositionByLane[laneName] = "finalized"; return "preview-finalized"; } } @@ -427,10 +460,14 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "existingOnly", context: "final", }); - if (finalized) { - params.finalizedPreviewByLane[laneName] = true; + if (finalized === "edited") { + params.previewDispositionByLane[laneName] = "finalized"; return "preview-finalized"; } + if (finalized === "retained") { + params.previewDispositionByLane[laneName] = "retained"; + return "preview-retained"; + } } else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) { params.log( `telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`, @@ -470,7 +507,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "always", context: "update", }); - if (updated) { + if (updated === "edited") { return "preview-updated"; } } diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts index 4d23ed8af8a..31d412c26b1 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/src/telegram/lane-delivery.test.ts @@ -42,7 +42,7 @@ function createHarness(params?: { const deletePreviewMessage = vi.fn().mockResolvedValue(undefined); const log = vi.fn(); const markDelivered = vi.fn(); - const finalizedPreviewByLane: Record = { answer: false, reasoning: false }; + const previewDispositionByLane = { answer: "transient", reasoning: "transient" } as const; const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string; @@ -52,7 +52,7 @@ function createHarness(params?: { const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - finalizedPreviewByLane, + previewDispositionByLane: { ...previewDispositionByLane }, draftMaxChars: params?.draftMaxChars ?? 4_096, applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }), sendPayload, @@ -129,7 +129,7 @@ describe("createLaneTextDeliverer", () => { expect(harness.sendPayload).not.toHaveBeenCalled(); }); - it("treats stop-created preview edit failures as delivered", async () => { + it("keeps stop-created preview when follow-up final edit fails", async () => { const harness = createHarness({ answerMessageIdAfterStop: 777 }); harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush")); @@ -140,11 +140,11 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(result).toBe("preview-retained"); expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); expect(harness.log).toHaveBeenCalledWith( - expect.stringContaining("keeping existing preview to avoid duplicate"), + expect.stringContaining("failed after stop flush; keeping existing preview"), ); }); @@ -172,7 +172,7 @@ describe("createLaneTextDeliverer", () => { ); }); - it("treats existing preview edit failure as delivered during final to avoid duplicate", async () => { + it("falls back to sendPayload when an existing preview final edit is rejected", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); @@ -183,31 +183,72 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); - expect(harness.editPreview).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(harness.markDelivered).toHaveBeenCalledTimes(1); - expect(harness.log).toHaveBeenCalledWith( - expect.stringContaining("keeping existing preview to avoid duplicate"), - ); - }); - - it("falls back to sendPayload when preview message is missing during final edit", async () => { - const harness = createHarness({ answerMessageId: 999 }); - harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); - - const result = await harness.deliverLaneText({ - laneName: "answer", - text: "Hello final", - payload: { text: "Hello final" }, - infoKind: "final", - }); - expect(result).toBe("sent"); expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Hello final" }), ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit rejected by Telegram; falling back"), + ); + }); + + it("keeps preview when Telegram reports the final edit target missing", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-retained"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit target missing; keeping existing preview without fallback"), + ); + }); + + it("falls back to sendPayload when the final edit fails before reaching Telegram", async () => { + const harness = createHarness({ answerMessageId: 999 }); + const err = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + harness.editPreview.mockRejectedValue(err); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Hello final" }), + ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("failed before reaching Telegram; falling back"), + ); + }); + + it("keeps preview when the final edit times out after the request may have landed", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("timeout: request timed out after 30000ms")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-retained"); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("may have landed despite network error; keeping existing preview"), + ); }); it("falls back to normal delivery when stop-created preview has no message id", async () => { @@ -385,11 +426,10 @@ describe("createLaneTextDeliverer", () => { }); // ── Duplicate message regression tests ────────────────────────────────── - // During final delivery, edit failures keep the existing preview to avoid - // sending a duplicate via sendPayload. The only exception is when the - // preview message itself is gone ("message to edit not found"). + // During final delivery, only ambiguous post-connect failures keep the + // preview. Definite non-delivery falls back to a real send. - it("keeps preview on API error during final to avoid duplicate", async () => { + it("falls back on API error during final", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.editPreview.mockRejectedValue(new Error("500: Internal Server Error")); @@ -400,13 +440,12 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(result).toBe("sent"); expect(harness.editPreview).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(harness.markDelivered).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledTimes(1); }); - it("falls back to sendPayload when archived preview is missing during final", async () => { + it("keeps archived preview when its final edit target is missing", async () => { const harness = createHarness(); harness.archivedAnswerPreviews.push({ messageId: 5555, @@ -423,8 +462,8 @@ describe("createLaneTextDeliverer", () => { }); expect(harness.editPreview).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).toHaveBeenCalledTimes(1); - expect(result).toBe("sent"); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(result).toBe("preview-retained"); }); it("deletes consumed boundary previews after fallback final send", async () => { diff --git a/src/telegram/lane-delivery.ts b/src/telegram/lane-delivery.ts index 213b05e1158..46c6fda235f 100644 --- a/src/telegram/lane-delivery.ts +++ b/src/telegram/lane-delivery.ts @@ -4,6 +4,7 @@ export { type DraftLaneState, type LaneDeliveryResult, type LaneName, + type LanePreviewDisposition, } from "./lane-delivery-text-deliverer.js"; export { createLaneDeliveryStateTracker,