fix: stabilize telegram draft boundary previews (#33842) (thanks @ngutman)

This commit is contained in:
Ayaan Zaidi
2026-03-04 08:55:13 +05:30
committed by Ayaan Zaidi
parent 5ce53095c5
commit 575bd77196
8 changed files with 463 additions and 73 deletions

View File

@@ -214,6 +214,7 @@ export const dispatchTelegramMessage = async ({
archivedAnswerPreviews.push({
messageId: preview.messageId,
textSnapshot: preview.textSnapshot,
deleteIfUnused: true,
});
}
: undefined,
@@ -239,7 +240,15 @@ export const dispatchTelegramMessage = async ({
const reasoningLane = lanes.reasoning;
let splitReasoningOnNextStream = false;
let skipNextAnswerMessageStartRotation = false;
let draftLaneEventQueue = Promise.resolve();
const reasoningStepState = createTelegramReasoningStepState();
const enqueueDraftLaneEvent = (task: () => Promise<void>): Promise<void> => {
const next = draftLaneEventQueue.then(task);
draftLaneEventQueue = next.catch((err) => {
logVerbose(`telegram: draft lane callback failed: ${String(err)}`);
});
return draftLaneEventQueue;
};
type SplitLaneSegment = { lane: LaneName; text: string };
type SplitLaneSegmentsResult = {
segments: SplitLaneSegment[];
@@ -265,17 +274,18 @@ export const dispatchTelegramMessage = async ({
lane.lastPartialText = "";
lane.hasStreamedMessage = false;
};
const rotateAnswerLaneForNewAssistantMessage = () => {
const rotateAnswerLaneForNewAssistantMessage = async () => {
let didForceNewMessage = false;
if (answerLane.hasStreamedMessage) {
const previewMessageId = answerLane.stream?.messageId();
// Only archive previews that still need a matching final text update.
// Once a preview has already been finalized, archiving it here causes
// cleanup to delete a user-visible final message on later media-only turns.
// Materialize the current streamed draft into a permanent message
// 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) {
archivedAnswerPreviews.push({
messageId: previewMessageId,
textSnapshot: answerLane.lastPartialText,
deleteIfUnused: false,
});
}
answerLane.stream?.forceNewMessage();
@@ -311,14 +321,14 @@ export const dispatchTelegramMessage = async ({
lane.lastPartialText = text;
laneStream.update(text);
};
const ingestDraftLaneSegments = (text: string | undefined) => {
const ingestDraftLaneSegments = async (text: string | undefined) => {
const split = splitTextIntoLaneSegments(text);
const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer");
if (hasAnswerSegment && finalizedPreviewByLane.answer) {
// 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.
skipNextAnswerMessageStartRotation = rotateAnswerLaneForNewAssistantMessage();
skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage();
}
for (const segment of split.segments) {
if (segment.lane === "reasoning") {
@@ -501,6 +511,11 @@ export const dispatchTelegramMessage = async ({
...prefixOptions,
typingCallbacks,
deliver: async (payload, info) => {
if (info.kind === "final") {
// Assistant callbacks are fire-and-forget; ensure queued boundary
// rotations/partials are applied before final delivery mapping.
await enqueueDraftLaneEvent(async () => {});
}
const previewButtons = (
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
)?.buttons;
@@ -610,42 +625,48 @@ export const dispatchTelegramMessage = async ({
disableBlockStreaming,
onPartialReply:
answerLane.stream || reasoningLane.stream
? (payload) => ingestDraftLaneSegments(payload.text)
? (payload) =>
enqueueDraftLaneEvent(async () => {
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onReasoningStream: reasoningLane.stream
? (payload) => {
// Split between reasoning blocks only when the next reasoning
// stream starts. Splitting at reasoning-end can orphan the active
// preview and cause duplicate reasoning sends on reasoning final.
if (splitReasoningOnNextStream) {
reasoningLane.stream?.forceNewMessage();
resetDraftLaneState(reasoningLane);
splitReasoningOnNextStream = false;
}
ingestDraftLaneSegments(payload.text);
}
? (payload) =>
enqueueDraftLaneEvent(async () => {
// Split between reasoning blocks only when the next reasoning
// stream starts. Splitting at reasoning-end can orphan the active
// preview and cause duplicate reasoning sends on reasoning final.
if (splitReasoningOnNextStream) {
reasoningLane.stream?.forceNewMessage();
resetDraftLaneState(reasoningLane);
splitReasoningOnNextStream = false;
}
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onAssistantMessageStart: answerLane.stream
? async () => {
reasoningStepState.resetForNextStep();
if (skipNextAnswerMessageStartRotation) {
skipNextAnswerMessageStartRotation = false;
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
if (skipNextAnswerMessageStartRotation) {
skipNextAnswerMessageStartRotation = false;
finalizedPreviewByLane.answer = false;
return;
}
await rotateAnswerLaneForNewAssistantMessage();
// Message-start is an explicit assistant-message boundary.
// 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;
return;
}
rotateAnswerLaneForNewAssistantMessage();
// Message-start is an explicit assistant-message boundary.
// 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;
}
})
: undefined,
onReasoningEnd: reasoningLane.stream
? () => {
// Split when/if a later reasoning block begins.
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
}
? () =>
enqueueDraftLaneEvent(async () => {
// Split when/if a later reasoning block begins.
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
})
: undefined,
onToolStart: statusReactionController
? async (payload) => {
@@ -656,6 +677,9 @@ export const dispatchTelegramMessage = async ({
},
}));
} finally {
// Upstream assistant callbacks are fire-and-forget; drain queued lane work
// before stream cleanup so boundary rotations/materialization complete first.
await draftLaneEventQueue;
// Must stop() first to flush debounced content before clear() wipes state.
const streamCleanupStates = new Map<
NonNullable<DraftLaneState["stream"]>,
@@ -670,7 +694,17 @@ export const dispatchTelegramMessage = async ({
if (!stream) {
continue;
}
const shouldClear = !finalizedPreviewByLane[laneState.laneName];
// Don't clear (delete) the stream if: (a) it was finalized, or
// (b) the active stream message is itself a boundary-finalized archive.
const activePreviewMessageId = stream.messageId();
const hasBoundaryFinalizedActivePreview =
laneState.laneName === "answer" &&
typeof activePreviewMessageId === "number" &&
archivedAnswerPreviews.some(
(p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId,
);
const shouldClear =
!finalizedPreviewByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview;
const existing = streamCleanupStates.get(stream);
if (!existing) {
streamCleanupStates.set(stream, { shouldClear });
@@ -685,6 +719,9 @@ export const dispatchTelegramMessage = async ({
}
}
for (const archivedPreview of archivedAnswerPreviews) {
if (archivedPreview.deleteIfUnused === false) {
continue;
}
try {
await bot.api.deleteMessage(chatId, archivedPreview.messageId);
} catch (err) {