diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index ba699470e35..0638d3b0331 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -505,6 +505,42 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("preserves earlier inline buttons when a later final only changes text", async () => { + const answerDraftStream = createSequencedDraftStream(1001); + const reasoningDraftStream = createDraftStream(); + const buttons = [[{ text: "Open", callback_data: "open" }]]; + createTelegramDraftStream + .mockImplementationOnce(() => answerDraftStream) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Message A partial" }); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + await dispatcherOptions.deliver( + { + text: "Message A final", + channelData: { telegram: { buttons } }, + }, + { kind: "final" }, + ); + await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 1001, + "Message A final Message B final", + expect.objectContaining({ buttons }), + ); + }); + it.each(["partial", "block"] as const)( "keeps finalized text preview when the next assistant message is media-only (%s mode)", async (streamMode) => { @@ -1230,6 +1266,21 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("clears stale reasoning preview when streamed turn ends without a final", async () => { + const { reasoningDraftStream } = setupDraftStreams({ + answerMessageId: 999, + reasoningMessageId: 111, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { + await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_step one_" }); + return { queuedFinal: false }; + }); + + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); + + expect(reasoningDraftStream.clear).toHaveBeenCalledTimes(1); + }); + it("falls back when all finals are skipped and clears preview", async () => { const draftStream = createDraftStream(999); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 8b86ae00c13..da6d0fe4b32 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -309,8 +309,22 @@ export const dispatchTelegramMessage = async ({ const getCurrentAnswerText = () => composeAnswerSegmentsText(); const getLastAnswerSegment = () => answerSegments[answerSegments.length - 1]; const getUnfinalizedAnswerSegments = () => answerSegments.filter((segment) => !segment.finalized); + const hasBufferedAnswerPayloadMetadata = (payload: ReplyPayload) => { + const previewButtons = ( + payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined + )?.buttons; + return ( + Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0 || Boolean(previewButtons) + ); + }; const bufferAnswerFinal = (payload: ReplyPayload) => { - bufferedAnswerFinal = { payload, text: composeAnswerSegmentsText() }; + const bufferedPayload = + bufferedAnswerFinal && + hasBufferedAnswerPayloadMetadata(bufferedAnswerFinal.payload) && + !hasBufferedAnswerPayloadMetadata(payload) + ? bufferedAnswerFinal.payload + : payload; + bufferedAnswerFinal = { payload: bufferedPayload, text: composeAnswerSegmentsText() }; }; const createAnswerSegment = (segmentStartsAfterFinal: boolean): AnswerSegmentState => { const segment: AnswerSegmentState = { @@ -754,7 +768,7 @@ export const dispatchTelegramMessage = async ({ }, })); await flushBufferedAnswerFinal(); - if (reasoningLane.hasStreamedMessage) { + if (queuedFinal && reasoningLane.hasStreamedMessage) { finalizedPreviewByLane.reasoning = true; } } catch (err) {