diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 66f1fb33e19..39c04892216 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -342,166 +342,6 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true }); }); - it("finalizes text-only replies by editing the preview message in place", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Hel" }); - await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object)); - expect(deliverReplies).not.toHaveBeenCalled(); - expect(draftStream.clear).not.toHaveBeenCalled(); - expect(draftStream.stop).toHaveBeenCalled(); - }); - - it("edits the preview message created during stop() final flush", async () => { - let messageId: number | undefined; - const draftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => messageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockImplementation(async () => { - messageId = 777; - }), - forceNewMessage: vi.fn(), - }; - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "777" }); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object)); - expect(deliverReplies).not.toHaveBeenCalled(); - expect(draftStream.stop).toHaveBeenCalled(); - }); - - it("primes stop() with final text when pending partial is below initial threshold", async () => { - let answerMessageId: number | undefined; - const answerDraftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockImplementation(async () => { - answerMessageId = 777; - }), - forceNewMessage: vi.fn(), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "no" }); - await dispatcherOptions.deliver({ text: "no problem" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "777" }); - - await dispatchWithContext({ context: createContext() }); - - expect(answerDraftStream.update).toHaveBeenCalledWith("no"); - expect(answerDraftStream.update).toHaveBeenLastCalledWith("no problem"); - expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "no problem", expect.any(Object)); - expect(deliverReplies).not.toHaveBeenCalled(); - expect(answerDraftStream.stop).toHaveBeenCalled(); - }); - - it("does not duplicate final delivery when stop-created preview edit fails", async () => { - let messageId: number | undefined; - const draftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => messageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockImplementation(async () => { - messageId = 777; - }), - forceNewMessage: vi.fn(), - }; - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockRejectedValue(new Error("500: edit failed after stop flush")); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object)); - expect(deliverReplies).not.toHaveBeenCalled(); - expect(draftStream.stop).toHaveBeenCalled(); - }); - - it("falls back to normal delivery when existing preview edit fails", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Hel" }); - await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockRejectedValue(new Error("500: preview edit failed")); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object)); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Hello final" })], - }), - ); - }); - - it("falls back to normal delivery when stop-created preview has no message id", async () => { - const draftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockReturnValue(undefined), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn(), - }; - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Short final" })], - }), - ); - expect(draftStream.stop).toHaveBeenCalled(); - }); - it("does not overwrite finalized preview when additional final payloads are sent", async () => { const draftStream = createDraftStream(999); createTelegramDraftStream.mockReturnValue(draftStream); @@ -565,29 +405,6 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.stop).toHaveBeenCalled(); }); - it("falls back to normal delivery when preview final is too long to edit", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - const longText = "x".repeat(5000); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: longText }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: longText })], - }), - ); - expect(draftStream.clear).toHaveBeenCalledTimes(1); - expect(draftStream.stop).toHaveBeenCalled(); - }); - it.each([ { label: "default account config", telegramCfg: {} }, { label: "account blockStreaming override", telegramCfg: { blockStreaming: true } }, @@ -1447,45 +1264,6 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); - it("edits stop-created preview when final text is shorter than buffered draft", async () => { - let answerMessageId: number | undefined; - const answerDraftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockImplementation(async () => { - answerMessageId = 999; - }), - forceNewMessage: vi.fn(), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ - text: "Let me check that file and confirm details for you.", - }); - await dispatcherOptions.deliver({ text: "Let me check that file." }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createContext(), streamMode: "block" }); - - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "Let me check that file.", - expect.any(Object), - ); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - it("does not edit preview message when final payload is an error", async () => { const draftStream = createDraftStream(999); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts new file mode 100644 index 00000000000..f3599f0fde6 --- /dev/null +++ b/src/telegram/lane-delivery.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js"; + +type MockStreamState = { + stream: NonNullable; + setMessageId: (value: number | undefined) => void; +}; + +function createMockStream(initialMessageId?: number): MockStreamState { + let messageId = initialMessageId; + const stream = { + update: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + messageId: vi.fn().mockImplementation(() => messageId), + clear: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forceNewMessage: vi.fn(), + previewMode: vi.fn().mockReturnValue("message"), + previewRevision: vi.fn().mockReturnValue(0), + } as unknown as NonNullable; + return { + stream, + setMessageId: (value) => { + messageId = value; + }, + }; +} + +function createHarness(params?: { + answerMessageId?: number; + draftMaxChars?: number; + answerMessageIdAfterStop?: number; +}) { + const answer = createMockStream(params?.answerMessageId); + const reasoning = createMockStream(); + const lanes: Record = { + answer: { stream: answer.stream, lastPartialText: "", hasStreamedMessage: false }, + reasoning: { stream: reasoning.stream, lastPartialText: "", hasStreamedMessage: false }, + }; + const sendPayload = vi.fn().mockResolvedValue(true); + const flushDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => { + await lane.stream?.flush(); + }); + const stopDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => { + if (lane === lanes.answer && params?.answerMessageIdAfterStop !== undefined) { + answer.setMessageId(params.answerMessageIdAfterStop); + } + await lane.stream?.stop(); + }); + const editPreview = vi.fn().mockResolvedValue(undefined); + const deletePreviewMessage = vi.fn().mockResolvedValue(undefined); + const log = vi.fn(); + const markDelivered = vi.fn(); + const finalizedPreviewByLane: Record = { answer: false, reasoning: false }; + const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string }> = []; + + const deliverLaneText = createLaneTextDeliverer({ + lanes, + archivedAnswerPreviews, + finalizedPreviewByLane, + draftMaxChars: params?.draftMaxChars ?? 4_096, + applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }), + sendPayload, + flushDraftLane, + stopDraftLane, + editPreview, + deletePreviewMessage, + log, + markDelivered, + }); + + return { + deliverLaneText, + lanes, + answer, + sendPayload, + flushDraftLane, + stopDraftLane, + editPreview, + log, + markDelivered, + }; +} + +describe("createLaneTextDeliverer", () => { + it("finalizes text-only replies by editing an existing preview message", async () => { + const harness = createHarness({ answerMessageId: 999 }); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-finalized"); + expect(harness.editPreview).toHaveBeenCalledWith( + expect.objectContaining({ + laneName: "answer", + messageId: 999, + text: "Hello final", + context: "final", + }), + ); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.stopDraftLane).toHaveBeenCalledTimes(1); + }); + + it("primes stop-created previews with final text before editing", async () => { + const harness = createHarness({ answerMessageIdAfterStop: 777 }); + harness.lanes.answer.lastPartialText = "no"; + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "no problem", + payload: { text: "no problem" }, + infoKind: "final", + }); + + expect(result).toBe("preview-finalized"); + expect(harness.answer.stream.update).toHaveBeenCalledWith("no problem"); + expect(harness.editPreview).toHaveBeenCalledWith( + expect.objectContaining({ + laneName: "answer", + messageId: 777, + text: "no problem", + }), + ); + expect(harness.sendPayload).not.toHaveBeenCalled(); + }); + + it("treats stop-created preview edit failures as delivered", async () => { + const harness = createHarness({ answerMessageIdAfterStop: 777 }); + harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Short final", + payload: { text: "Short final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-finalized"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("treating as delivered")); + }); + + it("falls back to normal delivery when editing an existing preview fails", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); + + 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" }), + ); + }); + + it("falls back to normal delivery when stop-created preview has no message id", async () => { + const harness = createHarness(); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Short final", + payload: { text: "Short final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.editPreview).not.toHaveBeenCalled(); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Short final" }), + ); + }); + + it("keeps existing preview when final text regresses", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.lanes.answer.lastPartialText = "Recovered final answer."; + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Recovered final answer", + payload: { text: "Recovered final answer" }, + infoKind: "final", + }); + + expect(result).toBe("preview-finalized"); + expect(harness.editPreview).not.toHaveBeenCalled(); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + }); + + it("falls back to normal delivery when final text exceeds preview edit limit", async () => { + const harness = createHarness({ answerMessageId: 999, draftMaxChars: 20 }); + const longText = "x".repeat(50); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: longText, + payload: { text: longText }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.editPreview).not.toHaveBeenCalled(); + expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText })); + expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long")); + }); +});