Skip to content

Commit f877e7e

Browse files
steipeteobviyus
andcommitted
fix(telegram): split stop-created preview finalization path
Refactor lane preview finalization into explicit branches so stop-created previews never duplicate sends when edit fails. Add Telegram dispatch regressions for: - stop-created preview edit failure (no duplicate send) - existing preview edit failure (fallback send preserved) - missing message id after stop-created flush (fallback send) Thanks @obviyus for the original preview-prime direction in #27449. Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 051fdcc commit f877e7e

File tree

2 files changed

+174
-29
lines changed

2 files changed

+174
-29
lines changed

src/telegram/bot-message-dispatch.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,83 @@ describe("dispatchTelegramMessage draft streaming", () => {
416416
expect(answerDraftStream.stop).toHaveBeenCalled();
417417
});
418418

419+
it("does not duplicate final delivery when stop-created preview edit fails", async () => {
420+
let messageId: number | undefined;
421+
const draftStream = {
422+
update: vi.fn(),
423+
flush: vi.fn().mockResolvedValue(undefined),
424+
messageId: vi.fn().mockImplementation(() => messageId),
425+
clear: vi.fn().mockResolvedValue(undefined),
426+
stop: vi.fn().mockImplementation(async () => {
427+
messageId = 777;
428+
}),
429+
forceNewMessage: vi.fn(),
430+
};
431+
createTelegramDraftStream.mockReturnValue(draftStream);
432+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
433+
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
434+
return { queuedFinal: true };
435+
});
436+
deliverReplies.mockResolvedValue({ delivered: true });
437+
editMessageTelegram.mockRejectedValue(new Error("500: edit failed after stop flush"));
438+
439+
await dispatchWithContext({ context: createContext() });
440+
441+
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object));
442+
expect(deliverReplies).not.toHaveBeenCalled();
443+
expect(draftStream.stop).toHaveBeenCalled();
444+
});
445+
446+
it("falls back to normal delivery when existing preview edit fails", async () => {
447+
const draftStream = createDraftStream(999);
448+
createTelegramDraftStream.mockReturnValue(draftStream);
449+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
450+
async ({ dispatcherOptions, replyOptions }) => {
451+
await replyOptions?.onPartialReply?.({ text: "Hel" });
452+
await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" });
453+
return { queuedFinal: true };
454+
},
455+
);
456+
deliverReplies.mockResolvedValue({ delivered: true });
457+
editMessageTelegram.mockRejectedValue(new Error("500: preview edit failed"));
458+
459+
await dispatchWithContext({ context: createContext() });
460+
461+
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object));
462+
expect(deliverReplies).toHaveBeenCalledWith(
463+
expect.objectContaining({
464+
replies: [expect.objectContaining({ text: "Hello final" })],
465+
}),
466+
);
467+
});
468+
469+
it("falls back to normal delivery when stop-created preview has no message id", async () => {
470+
const draftStream = {
471+
update: vi.fn(),
472+
flush: vi.fn().mockResolvedValue(undefined),
473+
messageId: vi.fn().mockReturnValue(undefined),
474+
clear: vi.fn().mockResolvedValue(undefined),
475+
stop: vi.fn().mockResolvedValue(undefined),
476+
forceNewMessage: vi.fn(),
477+
};
478+
createTelegramDraftStream.mockReturnValue(draftStream);
479+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
480+
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
481+
return { queuedFinal: true };
482+
});
483+
deliverReplies.mockResolvedValue({ delivered: true });
484+
485+
await dispatchWithContext({ context: createContext() });
486+
487+
expect(editMessageTelegram).not.toHaveBeenCalled();
488+
expect(deliverReplies).toHaveBeenCalledWith(
489+
expect.objectContaining({
490+
replies: [expect.objectContaining({ text: "Short final" })],
491+
}),
492+
);
493+
expect(draftStream.stop).toHaveBeenCalled();
494+
});
495+
419496
it("does not overwrite finalized preview when additional final payloads are sent", async () => {
420497
const draftStream = createDraftStream(999);
421498
createTelegramDraftStream.mockReturnValue(draftStream);

src/telegram/lane-delivery.ts

Lines changed: 97 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,55 @@ type ConsumeArchivedAnswerPreviewParams = {
104104
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
105105
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
106106

107+
const shouldSkipRegressivePreviewUpdate = (args: {
108+
currentPreviewText: string | undefined;
109+
text: string;
110+
skipRegressive: "always" | "existingOnly";
111+
hadPreviewMessage: boolean;
112+
}): boolean =>
113+
Boolean(args.currentPreviewText) &&
114+
args.currentPreviewText.startsWith(args.text) &&
115+
args.text.length < args.currentPreviewText.length &&
116+
(args.skipRegressive === "always" || args.hadPreviewMessage);
117+
118+
const tryEditPreviewMessage = async (args: {
119+
laneName: LaneName;
120+
messageId: number;
121+
text: string;
122+
context: "final" | "update";
123+
previewButtons?: TelegramInlineButtons;
124+
updateLaneSnapshot: boolean;
125+
lane: DraftLaneState;
126+
treatEditFailureAsDelivered: boolean;
127+
}): Promise<boolean> => {
128+
try {
129+
await params.editPreview({
130+
laneName: args.laneName,
131+
messageId: args.messageId,
132+
text: args.text,
133+
previewButtons: args.previewButtons,
134+
context: args.context,
135+
});
136+
if (args.updateLaneSnapshot) {
137+
args.lane.lastPartialText = args.text;
138+
}
139+
params.markDelivered();
140+
return true;
141+
} catch (err) {
142+
if (args.treatEditFailureAsDelivered) {
143+
params.log(
144+
`telegram: ${args.laneName} preview ${args.context} edit failed after stop-created flush; treating as delivered (${String(err)})`,
145+
);
146+
params.markDelivered();
147+
return true;
148+
}
149+
params.log(
150+
`telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`,
151+
);
152+
return false;
153+
}
154+
};
155+
107156
const tryUpdatePreviewForLane = async ({
108157
lane,
109158
laneName,
@@ -122,12 +171,39 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
122171
const lanePreviewMessageId = lane.stream.messageId();
123172
const hadPreviewMessage =
124173
typeof previewMessageIdOverride === "number" || typeof lanePreviewMessageId === "number";
125-
if (stopBeforeEdit) {
126-
if (!hadPreviewMessage && context === "final") {
127-
// If debounce prevented the first preview, replace stale pending partial text
128-
// before final stop() flush sends the first visible preview.
129-
lane.stream.update(text);
174+
const stopCreatesFirstPreview = stopBeforeEdit && !hadPreviewMessage && context === "final";
175+
if (stopCreatesFirstPreview) {
176+
// Final stop() can create the first visible preview message.
177+
// Prime pending text so the stop flush sends the final text snapshot.
178+
lane.stream.update(text);
179+
await params.stopDraftLane(lane);
180+
const previewMessageId = lane.stream.messageId();
181+
if (typeof previewMessageId !== "number") {
182+
return false;
130183
}
184+
const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane);
185+
const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({
186+
currentPreviewText,
187+
text,
188+
skipRegressive,
189+
hadPreviewMessage,
190+
});
191+
if (shouldSkipRegressive) {
192+
params.markDelivered();
193+
return true;
194+
}
195+
return tryEditPreviewMessage({
196+
laneName,
197+
messageId: previewMessageId,
198+
text,
199+
context,
200+
previewButtons,
201+
updateLaneSnapshot,
202+
lane,
203+
treatEditFailureAsDelivered: true,
204+
});
205+
}
206+
if (stopBeforeEdit) {
131207
await params.stopDraftLane(lane);
132208
}
133209
const previewMessageId =
@@ -138,34 +214,26 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
138214
return false;
139215
}
140216
const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane);
141-
const shouldSkipRegressive =
142-
Boolean(currentPreviewText) &&
143-
currentPreviewText.startsWith(text) &&
144-
text.length < currentPreviewText.length &&
145-
(skipRegressive === "always" || hadPreviewMessage);
217+
const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({
218+
currentPreviewText,
219+
text,
220+
skipRegressive,
221+
hadPreviewMessage,
222+
});
146223
if (shouldSkipRegressive) {
147224
params.markDelivered();
148225
return true;
149226
}
150-
try {
151-
await params.editPreview({
152-
laneName,
153-
messageId: previewMessageId,
154-
text,
155-
previewButtons,
156-
context,
157-
});
158-
if (updateLaneSnapshot) {
159-
lane.lastPartialText = text;
160-
}
161-
params.markDelivered();
162-
return true;
163-
} catch (err) {
164-
params.log(
165-
`telegram: ${laneName} preview ${context} edit failed; falling back to standard send (${String(err)})`,
166-
);
167-
return false;
168-
}
227+
return tryEditPreviewMessage({
228+
laneName,
229+
messageId: previewMessageId,
230+
text,
231+
context,
232+
previewButtons,
233+
updateLaneSnapshot,
234+
lane,
235+
treatEditFailureAsDelivered: false,
236+
});
169237
};
170238

171239
const consumeArchivedAnswerPreviewForFinal = async ({

0 commit comments

Comments
 (0)