fix(telegram): unify preview final edit outcomes

This commit is contained in:
Ayaan Zaidi
2026-03-10 09:13:13 +05:30
parent 7b25898adb
commit 38d5183f38
5 changed files with 189 additions and 114 deletions

View File

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

View File

@@ -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<LaneName, boolean> = {
answer: false,
reasoning: false,
const previewDispositionByLane: Record<LaneName, LanePreviewDisposition> = {
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 });

View File

@@ -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<LaneName, DraftLaneState>;
archivedAnswerPreviews: ArchivedPreview[];
finalizedPreviewByLane: Record<LaneName, boolean>;
previewDispositionByLane: Record<LaneName, LanePreviewDisposition>;
draftMaxChars: number;
applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload;
sendPayload: (payload: ReplyPayload) => Promise<boolean>;
@@ -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<boolean> => {
finalTextAlreadyLanded: boolean;
}): Promise<PreviewEditResult> => {
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<boolean> => {
const editPreview = (messageId: number, treatEditFailureAsDelivered: boolean) =>
}: TryUpdatePreviewParams): Promise<PreviewEditResult> => {
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<boolean> => {
): PreviewEditResult | Promise<PreviewEditResult> => {
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";
}
}

View File

@@ -42,7 +42,7 @@ function createHarness(params?: {
const deletePreviewMessage = vi.fn().mockResolvedValue(undefined);
const log = vi.fn();
const markDelivered = vi.fn();
const finalizedPreviewByLane: Record<LaneName, boolean> = { 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 () => {

View File

@@ -4,6 +4,7 @@ export {
type DraftLaneState,
type LaneDeliveryResult,
type LaneName,
type LanePreviewDisposition,
} from "./lane-delivery-text-deliverer.js";
export {
createLaneDeliveryStateTracker,