mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:50:42 +00:00
fix(telegram): unify preview final edit outcomes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type DraftLaneState,
|
||||
type LaneDeliveryResult,
|
||||
type LaneName,
|
||||
type LanePreviewDisposition,
|
||||
} from "./lane-delivery-text-deliverer.js";
|
||||
export {
|
||||
createLaneDeliveryStateTracker,
|
||||
|
||||
Reference in New Issue
Block a user