From 25fc85afa2182c0d6bab2394a6f80d43d70d9874 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 5 May 2026 22:13:10 +0530 Subject: [PATCH] test(telegram): cover single stream delivery --- .../telegram/src/bot-message-dispatch.test.ts | 3819 +---------------- extensions/telegram/src/lane-delivery.test.ts | 748 +--- 2 files changed, 323 insertions(+), 4244 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 64e406e357f..c5ddf8d6ca1 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1,13 +1,8 @@ import type { Bot } from "grammy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label-config.js"; import type { TelegramBotDeps } from "./bot-deps.js"; -import { - createSequencedTestDraftStream, - createTestDraftStream, -} from "./draft-stream.test-helpers.js"; -import { renderTelegramHtmlText } from "./format.js"; +import { createTestDraftStream } from "./draft-stream.test-helpers.js"; type DispatchReplyWithBufferedBlockDispatcherArgs = Parameters< TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"] @@ -138,7 +133,6 @@ vi.mock("./sticker-cache.js", () => ({ })); let dispatchTelegramMessage: typeof import("./bot-message-dispatch.js").dispatchTelegramMessage; -let getTelegramReplyFenceSizeForTests: typeof import("./bot-message-dispatch.js").getTelegramReplyFenceSizeForTests; let resetTelegramReplyFenceForTests: typeof import("./bot-message-dispatch.js").resetTelegramReplyFenceForTests; const telegramDepsForTest: TelegramBotDeps = { @@ -172,11 +166,8 @@ describe("dispatchTelegramMessage draft streaming", () => { type TelegramMessageContext = Parameters[0]["context"]; beforeAll(async () => { - ({ - dispatchTelegramMessage, - getTelegramReplyFenceSizeForTests, - resetTelegramReplyFenceForTests, - } = await import("./bot-message-dispatch.js")); + ({ dispatchTelegramMessage, resetTelegramReplyFenceForTests } = + await import("./bot-message-dispatch.js")); }); beforeEach(() => { @@ -268,8 +259,6 @@ describe("dispatchTelegramMessage draft streaming", () => { }); const createDraftStream = (messageId?: number) => createTestDraftStream({ messageId }); - const createSequencedDraftStream = (startMessageId = 1001) => - createSequencedTestDraftStream(startMessageId); function setupDraftStreams(params?: { answerMessageId?: number; reasoningMessageId?: number }) { const answerDraftStream = createDraftStream(params?.answerMessageId); @@ -547,12 +536,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); - it("skips answer draft preview for same-chat selected quotes", async () => { - deliverInboundReplyWithMessageSendContext.mockResolvedValue({ - status: "unsupported", - reason: "capability_mismatch", - capability: "nativeQuote", - }); + it("skips answer draft stream for same-chat selected quotes", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" }); return { queuedFinal: true }; @@ -582,16 +566,9 @@ describe("dispatchTelegramMessage draft streaming", () => { replyQuoteText: " quoted slice\n", }), ); - expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( - expect.objectContaining({ - requiredCapabilities: expect.objectContaining({ - nativeQuote: true, - }), - }), - ); }); - it("keeps answer draft preview for current message replies with native quote candidates", async () => { + it("keeps answer draft stream for current message replies with native quote candidates", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { @@ -687,7 +664,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies.mock.calls[0]?.[0]).not.toHaveProperty("replyQuoteByMessageId.1001"); }); - it("keeps answer draft preview for selected quotes when reply mode is off", async () => { + it("keeps answer draft stream for selected quotes when reply mode is off", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: true }); @@ -821,7 +798,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliveredPayload?.channelData).toBeUndefined(); }); - it("uses 30-char preview debounce for legacy block stream mode", async () => { + it("uses 30-char stream debounce for legacy block stream mode", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( @@ -842,7 +819,7 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); - it("keeps canonical block mode on the Telegram draft preview path", async () => { + it("keeps canonical block mode on the Telegram draft stream path", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( @@ -864,3662 +841,198 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith("HelloWorld"); }); - it("does not create a Telegram progress draft for a text-only final", async () => { - const draftStream = createSequencedDraftStream(2001); - createTelegramDraftStream.mockReturnValue(draftStream); + it("streams text-only finals into the answer message", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.update).toHaveBeenCalledWith("Final answer"); + expect(answerDraftStream.stop).toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(emitInternalMessageSentHook).toHaveBeenCalledWith( + expect.objectContaining({ content: "Final answer", messageId: 2001 }), + ); + }); + + it("streams block and final text through the same answer message", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReplyStart?.(); - await replyOptions?.onAssistantMessageStart?.(); - await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + await replyOptions?.onPartialReply?.({ text: "Working" }); + await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); return { queuedFinal: true }; }, ); - await dispatchWithContext({ - context: createContext(), - streamMode: "progress", - telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Working"); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Done"); + expect(answerDraftStream.stop).toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + + it("rotates the answer stream only after a finalized assistant message", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Message A final"); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B partial"); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Message B final"); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("keeps compaction replay on the same answer stream", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Partial before compaction" }); + await replyOptions?.onCompactionStart?.(); + await replyOptions?.onPartialReply?.({ text: "Partial before compaction" }); + await dispatcherOptions.deliver({ text: "Final after compaction" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Partial before compaction"); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Final after compaction"); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("streams the first long final chunk and sends follow-up chunks", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + const longText = "one ".repeat(80); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: longText }, { kind: "final" }); + return { queuedFinal: true }; }); - expect(draftStream.update).not.toHaveBeenCalled(); - expect(draftStream.forceNewMessage).not.toHaveBeenCalled(); + await dispatchWithContext({ context: createContext(), textLimit: 80 }); + + const firstChunk = answerDraftStream.update.mock.calls.at(-1)?.[0] ?? ""; + expect(firstChunk.length).toBeLessThanOrEqual(80); + expect(deliverReplies).toHaveBeenCalled(); + const followUpTexts = deliverReplies.mock.calls.flatMap((call: unknown[]) => + ((call[0] as { replies?: Array<{ text?: string }> }).replies ?? []).map( + (reply) => reply.text ?? "", + ), + ); + expect(followUpTexts.join("")).toContain("one"); expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + + it("falls back to normal send for media and clears the pending stream", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { text: "Photo", mediaUrl: "https://example.com/a.png" }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.clear).toHaveBeenCalled(); + expect(answerDraftStream.update).not.toHaveBeenCalledWith("Photo"); expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ - replies: [expect.objectContaining({ text: "Final answer" })], + replies: [ + expect.objectContaining({ text: "Photo", mediaUrl: "https://example.com/a.png" }), + ], }), ); }); - it("keeps non-command Telegram progress draft lines across post-tool assistant boundaries", async () => { - const draftStream = createSequencedDraftStream(2001); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReplyStart?.(); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onItemEvent?.({ kind: "search", progressText: "docs lookup" }); - await replyOptions?.onItemEvent?.({ progressText: "tests passed" }); - await replyOptions?.onAssistantMessageStart?.(); - await dispatcherOptions.deliver({ text: "Final after tool" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - - await dispatchWithContext({ - context: createContext(), - streamMode: "progress", - telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, + it("falls back to normal send for error payloads and clears the pending stream", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Boom", isError: true }, { kind: "final" }); + return { queuedFinal: true }; }); - expect(draftStream.update).toHaveBeenCalledWith( - expect.stringMatching(/^Shelling\n`šŸ”Ž Web Search: docs lookup`\n• `tests passed`$/), + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.clear).toHaveBeenCalled(); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ replies: [expect.objectContaining({ text: "Boom" })] }), ); - expect(draftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(draftStream.materialize).not.toHaveBeenCalled(); + }); + + it("streams button-bearing text into the same message", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + const buttons = [[{ text: "OK", callback_data: "ok" }]]; + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { text: "Choose", channelData: { telegram: { buttons } } }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.update).toHaveBeenCalledWith("Choose"); expect(editMessageTelegram).toHaveBeenCalledWith( 123, 2001, - "Final after tool", - expect.any(Object), - ); - expect(draftStream.clear).not.toHaveBeenCalled(); - }); - - it("cleans up tool-only Telegram previews archived at assistant boundaries", async () => { - const draftStream = createSequencedDraftStream(2001); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await replyOptions?.onItemEvent?.({ - kind: "command", - name: "exec", - progressText: "exec git status", - }); - await replyOptions?.onAssistantMessageStart?.(); - return { queuedFinal: false }; - }); - - const bot = createBot(); - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - telegramCfg: { streaming: { mode: "partial" } }, - bot, - }); - - expect(draftStream.update).toHaveBeenCalledWith( - expect.stringMatching(/`šŸ› ļø Exec: exec git status`$/), - ); - expect(draftStream.materialize).toHaveBeenCalled(); - expect(draftStream.forceNewMessage).toHaveBeenCalled(); - expect(bot.api.deleteMessage).toHaveBeenCalledWith(123, 2001); - }); - - it("streams Telegram command progress text by default when preview streaming is active", async () => { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await replyOptions?.onItemEvent?.({ - kind: "command", - name: "exec", - progressText: "exec ls ~/Desktop", - }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(draftStream.update).toHaveBeenCalledWith( - expect.stringMatching(/\n`šŸ› ļø Exec`\n`šŸ› ļø Exec: exec ls ~\/Desktop`$/), - ); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - suppressDefaultToolProgressMessages: true, - }), - }), - ); - }); - - it("can hide Telegram command progress text by config", async () => { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await replyOptions?.onItemEvent?.({ - kind: "command", - name: "exec", - progressText: "exec ls ~/Desktop", - }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - telegramCfg: { streaming: { mode: "partial", preview: { commandText: "status" } } }, - }); - - expect(draftStream.update).toHaveBeenCalledWith(expect.stringMatching(/\n`šŸ› ļø Exec`$/)); - expect(draftStream.update.mock.calls.at(-1)?.[0]).not.toContain("exec ls"); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - suppressDefaultToolProgressMessages: true, - }), - }), - ); - }); - - it("suppresses Telegram tool progress when explicitly disabled", async () => { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await replyOptions?.onItemEvent?.({ progressText: "exec ls ~/Desktop" }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - telegramCfg: { streaming: { preview: { toolProgress: false } } }, - }); - - expect(draftStream.update).not.toHaveBeenCalled(); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - suppressDefaultToolProgressMessages: true, - }), - }), - ); - }); - - it("suppresses default tool progress messages when answer preview streaming is off", async () => { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await replyOptions?.onItemEvent?.({ progressText: "exec ls ~/Desktop" }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ context: createContext(), streamMode: "off" }); - - expect(createTelegramDraftStream).not.toHaveBeenCalled(); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - suppressDefaultToolProgressMessages: true, - }), - }), - ); - }); - - it("keeps non-command Telegram tool progress links inside code formatting", async () => { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await replyOptions?.onItemEvent?.({ - kind: "search", - progressText: "read [label](tg://user?id=123)", - }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - }); - - const lastPreviewText = draftStream.update.mock.calls.at(-1)?.[0]; - expect(lastPreviewText).toMatch( - /\n`šŸ› ļø Exec`\n`šŸ”Ž Web Search: read \[label\]\(tg:\/\/user\?id=123\)`$/, - ); - expect(renderTelegramHtmlText(lastPreviewText ?? "")).not.toContain(" { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - const longProgress = `${"`".repeat(1000)}${"x".repeat(1000)}[label](tg://user?id=123)`; - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onItemEvent?.({ progressText: longProgress }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - telegramCfg: { streaming: { preview: { toolProgress: true } } }, - }); - - const lastPreviewText = draftStream.update.mock.calls.at(-1)?.[0] ?? ""; - const progressLine = lastPreviewText.split("\n").at(1) ?? ""; - - expect(lastPreviewText.length).toBeLessThan(340); - expect(progressLine).toMatch(/^• `.*…`$/); - expect(progressLine).toContain("…"); - expect(renderTelegramHtmlText(lastPreviewText)).not.toContain(" { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - const breakoutProgress = `${"`".repeat(10)} [label](tg://user?id=123)`; - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onItemEvent?.({ progressText: breakoutProgress }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - telegramCfg: { streaming: { preview: { toolProgress: true } } }, - }); - - const lastPreviewText = draftStream.update.mock.calls.at(-1)?.[0] ?? ""; - - expect(lastPreviewText).toContain(`• \`'''''''''' [label](tg://user?id=123)\``); - expect(renderTelegramHtmlText(lastPreviewText)).not.toContain(" { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext(), - telegramCfg: { streaming: { block: { enabled: true } } }, - }); - - expect(createTelegramDraftStream).not.toHaveBeenCalled(); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - disableBlockStreaming: false, - onPartialReply: undefined, - }), - }), - ); - }); - - it("sends error replies silently when silentErrorReplies is enabled", async () => { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext(), - telegramCfg: { silentErrorReplies: true }, - }); - - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - silent: true, - replies: [expect.objectContaining({ isError: true })], - }), - ); - }); - - it("queues silent error replies through durable delivery with silent preserved", async () => { - deliverInboundReplyWithMessageSendContext.mockResolvedValue({ - status: "handled_visible", - delivery: { - messageIds: ["durable-silent"], - visibleReplySent: true, - }, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext(), - telegramCfg: { silentErrorReplies: true }, - }); - - expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "telegram", - payload: expect.objectContaining({ isError: true }), - silent: true, - }), + "Choose", + expect.objectContaining({ buttons }), ); expect(deliverReplies).not.toHaveBeenCalled(); }); - it("keeps error replies notifying by default", async () => { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - silent: false, - replies: [expect.objectContaining({ isError: true })], - }), - ); - }); - - it("keeps fallback replies silent after an error reply is skipped", async () => { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - dispatcherOptions.onSkip?.( - { text: "oops", isError: true }, - { kind: "final", reason: "empty" }, - ); - return { queuedFinal: false }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext(), - telegramCfg: { silentErrorReplies: true }, - }); - - expect(deliverReplies).toHaveBeenLastCalledWith( - expect.objectContaining({ - silent: true, - replies: [expect.objectContaining({ text: expect.any(String) })], - }), - ); - }); - - it("keeps block streaming enabled when session reasoning level is on", async () => { - loadSessionStore.mockReturnValue({ - s1: { reasoningLevel: "on" }, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Reasoning:\n_step_" }, { kind: "block" }); - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext({ - ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], - }), - }); - - expect(createTelegramDraftStream).not.toHaveBeenCalled(); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - disableBlockStreaming: false, - }), - }), - ); - expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true }); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Reasoning:\n_step_" })], - }), - ); - }); - - it("streams reasoning draft updates even when answer stream mode is off", async () => { - loadSessionStore.mockReturnValue({ - s1: { reasoningLevel: "stream" }, - }); - const reasoningDraftStream = createDraftStream(111); - createTelegramDraftStream.mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_step_" }); - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext({ - ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], - }), - streamMode: "off", - }); - - expect(createTelegramDraftStream).toHaveBeenCalledTimes(1); - expect(reasoningDraftStream.update).toHaveBeenCalledWith("Reasoning:\n_step_"); - expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true }); - }); - - it("does not expose reasoning preview callbacks unless session reasoning is stream", async () => { - let seenReasoningCallback: unknown; - const answerDraftStream = createDraftStream(999); - createTelegramDraftStream.mockImplementationOnce(() => answerDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - seenReasoningCallback = replyOptions?.onReasoningStream; - await replyOptions?.onPartialReply?.({ - text: "internal chain of thoughtVisible answer", - }); - return { queuedFinal: false }; - }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(seenReasoningCallback).toBeUndefined(); - expect(createTelegramDraftStream).toHaveBeenCalledTimes(1); - expect(answerDraftStream.update).toHaveBeenCalledWith("Visible answer"); - }); - - it("does not overwrite finalized preview when additional final payloads are sent", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" }); - await dispatcherOptions.deliver( - { text: "āš ļø Recovered tool error details" }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "Primary result", - expect.any(Object), - ); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "āš ļø Recovered tool error details" })], - }), - ); - expect(draftStream.clear).not.toHaveBeenCalled(); - expect(draftStream.stop).toHaveBeenCalled(); - }); - - it("emits only the internal message:sent hook when a final answer stays in preview", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" }); - return { queuedFinal: true }; - }); - - await dispatchWithContext({ - context: createContext({ - ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], - }), - }); - - expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "Primary result", - expect.any(Object), - ); - expect(emitInternalMessageSentHook).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKeyForInternalHooks: "s1", - chatId: "123", - content: "Primary result", - success: true, - messageId: 999, - }), - ); - }); - - it("keeps streamed preview visible when final text regresses after a tool warning", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Recovered final answer." }); - await dispatcherOptions.deliver( - { text: "āš ļø Recovered tool error details", isError: true }, - { kind: "tool" }, - ); - await dispatcherOptions.deliver({ text: "Recovered final answer" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - // Regressive final ("answer." -> "answer") should keep the preview instead - // of clearing it and leaving only the tool warning visible. - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "āš ļø Recovered tool error details" })], - }), - ); - expect(draftStream.clear).not.toHaveBeenCalled(); - expect(draftStream.stop).toHaveBeenCalled(); - }); - - it.each([ - { label: "default account config", telegramCfg: {} }, - { - label: "account blockStreaming override", - telegramCfg: { streaming: { block: { enabled: true } } }, - }, - ])("disables block streaming when streamMode is off ($label)", async ({ telegramCfg }) => { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "off", - telegramCfg, - }); - - expect(createTelegramDraftStream).not.toHaveBeenCalled(); - expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ - replyOptions: expect.objectContaining({ - disableBlockStreaming: true, - }), - }), - ); - }); - - it("forces new message when assistant message restarts", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "First response" }); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "After tool call" }); - await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1); - }); - - it("materializes boundary preview and keeps it when no matching final arrives", async () => { - const answerDraftStream = createDraftStream(999); - answerDraftStream.materialize.mockResolvedValue(4321); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Before tool boundary" }); - await replyOptions?.onAssistantMessageStart?.(); - return { queuedFinal: false }; - }); - - const bot = createBot(); - await dispatchWithContext({ context: createContext(), streamMode: "partial", bot }); - - expect(answerDraftStream.materialize).toHaveBeenCalledTimes(1); - expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(answerDraftStream.clear).toHaveBeenCalledTimes(1); - const deleteMessageCalls = ( - bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } } - ).deleteMessage.mock.calls; - expect(deleteMessageCalls).not.toContainEqual([123, 4321]); - }); - - it("waits for queued boundary rotation before final lane delivery", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - let resolveMaterialize: ((value: number | undefined) => void) | undefined; - const materializePromise = new Promise((resolve) => { - resolveMaterialize = resolve; - }); - answerDraftStream.materialize.mockImplementation(() => materializePromise); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - const startPromise = replyOptions?.onAssistantMessageStart?.(); - const partialPromise = replyOptions?.onPartialReply?.({ text: "Message B partial" }); - const finalPromise = dispatcherOptions.deliver( - { text: "Message B final" }, - { kind: "final" }, - ); - resolveMaterialize?.(1001); - await startPromise; - await partialPromise; - await finalPromise; - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledTimes(2); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - }); - - it("preserves pre-rotation skip until queued message-start callbacks flush", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - await replyOptions?.onPartialReply?.({ text: "Message B early" }); - void replyOptions?.onAssistantMessageStart?.(); - 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(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - }); - - it("does not double-rotate when assistant_message_start arrives after final delivery drains", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - await replyOptions?.onPartialReply?.({ text: "Message B early" }); - await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); - await replyOptions?.onAssistantMessageStart?.(); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - }); - - it("clears active preview even when an unrelated boundary archive exists", async () => { - const answerDraftStream = createDraftStream(999); - answerDraftStream.materialize.mockResolvedValue(4321); - answerDraftStream.forceNewMessage.mockImplementation(() => { - answerDraftStream.setMessageId(5555); - }); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Before tool boundary" }); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Unfinalized next preview" }); - return { queuedFinal: false }; - }); - - const bot = createBot(); - await dispatchWithContext({ context: createContext(), streamMode: "partial", bot }); - - expect(answerDraftStream.clear).toHaveBeenCalledTimes(1); - const deleteMessageCalls = ( - bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } } - ).deleteMessage.mock.calls; - expect(deleteMessageCalls).not.toContainEqual([123, 4321]); - }); - - it("queues late partials behind async boundary materialization", async () => { - const answerDraftStream = createDraftStream(999); - let resolveMaterialize: ((value: number | undefined) => void) | undefined; - const materializePromise = new Promise((resolve) => { - resolveMaterialize = resolve; - }); - answerDraftStream.materialize.mockImplementation(() => materializePromise); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - - // Simulate provider fire-and-forget ordering: boundary callback starts - // and a new partial arrives before boundary materialization resolves. - const startPromise = replyOptions?.onAssistantMessageStart?.(); - const nextPartialPromise = replyOptions?.onPartialReply?.({ text: "Message B early" }); - - expect(answerDraftStream.update).toHaveBeenCalledTimes(1); - resolveMaterialize?.(4321); - - await startPromise; - await nextPartialPromise; - return { queuedFinal: false }; - }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(answerDraftStream.materialize).toHaveBeenCalledTimes(1); - expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(answerDraftStream.update).toHaveBeenCalledTimes(2); - expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B early"); - const boundaryRotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]; - const secondUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1]; - expect(boundaryRotationOrder).toBeLessThan(secondUpdateOrder); - }); - - it("sends final-only text without creating a synthetic preview before real partials", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - // Final-only first response (no streamed partials). - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - // Simulate provider ordering bug: first chunk arrives before message-start callback. - await replyOptions?.onPartialReply?.({ text: "Message B early" }); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B partial" }); - 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(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Message A final" })], - }), - ); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message B final", - expect.any(Object), - ); - }); - - it("uses the active preview as the first chunk for long text finals", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - const finalText = `${"A".repeat(70)}${"B".repeat(70)}`; - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Working preview" }); - await dispatcherOptions.deliver({ text: finalText, replyToId: "456" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - textLimit: 80, - }); - - const editedText = editMessageTelegram.mock.calls[0]?.[2] as string; - const followUpText = - (deliverReplies.mock.calls[0]?.[0] as { replies?: Array<{ text?: string }> })?.replies?.[0] - ?.text ?? ""; - - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editedText.length).toBeLessThanOrEqual(80); - expect(followUpText.length).toBeGreaterThan(0); - expect(`${editedText}${followUpText}`).toBe(finalText); - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.not.objectContaining({ replyToId: expect.any(String) })], - }), - ); - expect(answerDraftStream.clear).not.toHaveBeenCalled(); - }); - - it("uses the active preview as the first chunk for three-chunk long text finals", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - const finalText = `${"A".repeat(70)}${"B".repeat(70)}${"C".repeat(70)}`; - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Working preview" }); - await dispatcherOptions.deliver({ text: finalText, replyToId: "456" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - await dispatchWithContext({ - context: createContext(), - streamMode: "partial", - textLimit: 80, - }); - - const editedText = editMessageTelegram.mock.calls[0]?.[2] as string; - const followUpReplies = - (deliverReplies.mock.calls[0]?.[0] as { replies?: Array<{ text?: string }> })?.replies ?? []; - const followUpText = followUpReplies.map((reply) => reply.text ?? "").join(""); - - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editedText.length).toBeLessThanOrEqual(80); - expect(followUpReplies).toHaveLength(1); - expect(followUpText.length).toBeGreaterThan(80); - expect(`${editedText}${followUpText}`).toBe(finalText); - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.not.objectContaining({ replyToId: expect.any(String) })], - }), - ); - expect(answerDraftStream.clear).not.toHaveBeenCalled(); - }); - - it("does not force new message on first assistant message start", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - // First assistant message starts (no previous output) - await replyOptions?.onAssistantMessageStart?.(); - // Partial updates - await replyOptions?.onPartialReply?.({ text: "Hello" }); - await replyOptions?.onPartialReply?.({ text: "Hello world" }); - await dispatcherOptions.deliver({ text: "Hello world" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "block" }); - - // First message start shouldn't trigger forceNewMessage (no previous output) - expect(draftStream.forceNewMessage).not.toHaveBeenCalled(); - }); - - it("rotates before a late second-message partial so finalized preview is not overwritten", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - // Simulate provider ordering bug: first chunk arrives before message-start callback. - await replyOptions?.onPartialReply?.({ text: "Message B early" }); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B partial" }); - 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(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B early"); - const boundaryRotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]; - const secondUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1]; - expect(boundaryRotationOrder).toBeLessThan(secondUpdateOrder); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - }); - - it("does not skip message-start rotation when pre-rotation did not force a new message", async () => { - const answerDraftStream = createSequencedDraftStream(1002); - answerDraftStream.setMessageId(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - // First message has only final text (no streamed partials), so answer lane - // reaches finalized state with hasStreamedMessage still false. - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - // Provider ordering bug: next message partial arrives before message-start. - await replyOptions?.onPartialReply?.({ text: "Message B early" }); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B partial" }); - await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - const bot = createBot(); - - await dispatchWithContext({ context: createContext(), streamMode: "partial", bot }); - - // Early pre-rotation could not force (no streamed partials yet), so the - // real assistant message_start must still rotate once. - expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Message B early"); - expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B partial"); - const earlyUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0]; - const boundaryRotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]; - const secondUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1]; - expect(earlyUpdateOrder).toBeLessThan(boundaryRotationOrder); - expect(boundaryRotationOrder).toBeLessThan(secondUpdateOrder); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - expect((bot.api.deleteMessage as ReturnType).mock.calls).toHaveLength(0); - }); - - it("does not trigger late pre-rotation mid-message after an explicit assistant message start", async () => { - const answerDraftStream = createDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - // Message A finalizes without streamed partials. - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - // Message B starts normally before partials. - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B first chunk" }); - await replyOptions?.onPartialReply?.({ text: "Message B second chunk" }); - 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" }); - - // The explicit message_start boundary must clear finalized state so - // same-message partials do not force a new preview mid-stream. - expect(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Message B first chunk"); - expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B second chunk"); - }); - - it("does not rotate the streamed preview when compaction retries replay the same assistant message", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onCompactionStart?.(); - await replyOptions?.onCompactionEnd?.(); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onPartialReply?.({ text: "Message A partial extended" }); - await dispatcherOptions.deliver({ text: "Message A 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(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(answerDraftStream.materialize).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Message A final", - expect.any(Object), - ); - }); - - it("clears the compaction replay skip after the retried message finalizes", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onCompactionStart?.(); - await replyOptions?.onCompactionEnd?.(); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message A partial extended" }); - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B partial" }); - 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(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - }); - - it("preserves the compaction replay flag until queued retry callbacks flush", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onCompactionStart?.(); - await replyOptions?.onCompactionEnd?.(); - void replyOptions?.onAssistantMessageStart?.(); - await dispatcherOptions.deliver({ text: "Message A 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(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Message A final", - expect.any(Object), - ); - }); - - it("keeps the existing preview when the retried answer only arrives as final text", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onCompactionStart?.(); - await replyOptions?.onCompactionEnd?.(); - await replyOptions?.onAssistantMessageStart?.(); - 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(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(answerDraftStream.materialize).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Message B final", - expect.any(Object), - ); - }); - - it("keeps the transient preview when a local exec approval prompt is suppressed after compaction", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onCompactionStart?.(); - await replyOptions?.onCompactionEnd?.(); - await dispatcherOptions.deliver( - { - text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", - channelData: { - execApproval: { - approvalId: "7f423fdc-1111-2222-3333-444444444444", - approvalSlug: "7f423fdc", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }, - { kind: "tool" }, - ); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B partial" }); - 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", - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - - expect(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Message B final", - expect.any(Object), - ); - }); - - it("rotates after a visible tool payload lands between compaction and the next assistant message", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Message A partial" }); - await replyOptions?.onCompactionStart?.(); - await replyOptions?.onCompactionEnd?.(); - await dispatcherOptions.deliver( - { mediaUrl: "file:///tmp/tool-result.png" }, - { kind: "tool" }, - ); - await replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message B partial" }); - 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(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ mediaUrl: "file:///tmp/tool-result.png" })], - }), - ); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - expect.any(Number), - "Message B final", - expect.any(Object), - ); - }); - - it("finalizes multi-message assistant stream to matching preview messages in order", async () => { - const answerDraftStream = createSequencedDraftStream(1001); - const reasoningDraftStream = createDraftStream(); - 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 replyOptions?.onAssistantMessageStart?.(); - await replyOptions?.onPartialReply?.({ text: "Message C partial" }); - - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); - await dispatcherOptions.deliver({ text: "Message C 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(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 3, - 123, - 1003, - "Message C final", - expect.any(Object), - ); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("maps finals correctly when first preview id resolves after message boundary", async () => { - let answerMessageId: number | undefined; - let answerDraftParams: - | { - onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; - } - | undefined; - const answerDraftStream = { - update: vi.fn().mockImplementation((text: string) => { - if (text.includes("Message B")) { - answerMessageId = 1002; - } - }), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - answerMessageId = undefined; - }), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce((params) => { - answerDraftParams = params as typeof answerDraftParams; - return 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" }); - // Simulate late resolution of message A preview ID after boundary rotation. - answerDraftParams?.onSupersededPreview?.({ - messageId: 1001, - textSnapshot: "Message A partial", - }); - - await dispatcherOptions.deliver({ text: "Message A final" }, { 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).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("keeps the active preview when an archived final edit target is missing", async () => { - let answerMessageId: number | undefined; - let answerDraftParams: - | { - onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; - } - | undefined; - const answerDraftStream = { - update: vi.fn().mockImplementation((text: string) => { - if (text.includes("Message B")) { - answerMessageId = 1002; - } - }), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - answerMessageId = undefined; - }), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce((params) => { - answerDraftParams = params as typeof answerDraftParams; - return 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" }); - answerDraftParams?.onSupersededPreview?.({ - messageId: 1001, - textSnapshot: "Message A partial", - }); - - await dispatcherOptions.deliver({ text: "Message A final" }, { 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(), streamMode: "partial" }); - - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(answerDraftStream.clear).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("still finalizes the active preview after an archived final edit is retained", async () => { - let answerMessageId: number | undefined; - let answerDraftParams: - | { - onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; - } - | undefined; - const answerDraftStream = { - update: vi.fn().mockImplementation((text: string) => { - if (text.includes("Message B")) { - answerMessageId = 1002; - } - }), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - answerMessageId = undefined; - }), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce((params) => { - answerDraftParams = params as typeof answerDraftParams; - return 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" }); - answerDraftParams?.onSupersededPreview?.({ - messageId: 1001, - textSnapshot: "Message A partial", - }); - - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram - .mockRejectedValueOnce(new Error("400: Bad Request: message to edit not found")) - .mockResolvedValueOnce({ ok: true, chatId: "123", messageId: "1002" }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - expect(answerDraftStream.clear).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("clears the active preview when a later final falls back after archived retain", async () => { - let answerMessageId: number | undefined; - let answerDraftParams: - | { - onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; - } - | undefined; - const answerDraftStream = { - update: vi.fn().mockImplementation((text: string) => { - if (text.includes("Message B")) { - answerMessageId = 1002; - } - }), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - answerMessageId = undefined; - }), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce((params) => { - answerDraftParams = params as typeof answerDraftParams; - return 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" }); - answerDraftParams?.onSupersededPreview?.({ - messageId: 1001, - textSnapshot: "Message A partial", - }); - - await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); - await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443"); - (preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED"; - editMessageTelegram - .mockRejectedValueOnce(new Error("400: Bad Request: message to edit not found")) - .mockRejectedValueOnce(preConnectErr); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - const finalTextSentViaDeliverReplies = deliverReplies.mock.calls.some((call: unknown[]) => - (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( - (r: { text?: string }) => r.text === "Message B final", - ), - ); - expect(finalTextSentViaDeliverReplies).toBe(true); - expect(answerDraftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("keeps finalized text preview when the next assistant message is media-only", async () => { - let answerMessageId: number | undefined = 1001; - const answerDraftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - answerMessageId = undefined; - }), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "First message preview" }); - await dispatcherOptions.deliver({ text: "First message final" }, { kind: "final" }); - await replyOptions?.onAssistantMessageStart?.(); - await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/voice.ogg" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - const bot = createBot(); - - await dispatchWithContext({ context: createContext(), streamMode: "partial", bot }); - - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "First message final", - expect.any(Object), - ); - const deleteMessageCalls = ( - bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } } - ).deleteMessage.mock.calls; - expect(deleteMessageCalls).not.toContainEqual([123, 1001]); - }); - - it("maps finals correctly when archived preview id arrives during final flush", async () => { - let answerMessageId: number | undefined; - let answerDraftParams: - | { - onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; - } - | undefined; - let emittedSupersededPreview = false; - const answerDraftStream = { - update: vi.fn().mockImplementation((text: string) => { - if (text.includes("Message B")) { - answerMessageId = 1002; - } - }), - flush: vi.fn().mockImplementation(async () => { - if (!emittedSupersededPreview) { - emittedSupersededPreview = true; - answerDraftParams?.onSupersededPreview?.({ - messageId: 1001, - textSnapshot: "Message A partial", - }); - } - }), - messageId: vi.fn().mockImplementation(() => answerMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - answerMessageId = undefined; - }), - }; - const reasoningDraftStream = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce((params) => { - answerDraftParams = params as typeof answerDraftParams; - return 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" }, { 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).toHaveBeenNthCalledWith( - 1, - 123, - 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, - "Message B final", - expect.any(Object), - ); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("splits reasoning lane only when a later reasoning block starts", async () => { - const { reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_first block_" }); - await replyOptions?.onReasoningEnd?.(); - expect(reasoningDraftStream.forceNewMessage).not.toHaveBeenCalled(); - await replyOptions?.onPartialReply?.({ text: "checking files..." }); - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_second block_" }); - await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - }); - - it("queues reasoning-end split decisions behind queued reasoning deltas", async () => { - const { reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - // Simulate fire-and-forget upstream ordering: reasoning_end arrives - // before the queued reasoning delta callback has finished. - const firstReasoningPromise = replyOptions?.onReasoningStream?.({ - text: "Reasoning:\n_first block_", - }); - await replyOptions?.onReasoningEnd?.(); - await firstReasoningPromise; - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_second block_" }); - await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); - }); - - it("cleans superseded reasoning previews after lane rotation", async () => { - let reasoningDraftParams: - | { - onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; - } - | undefined; - const answerDraftStream = createDraftStream(999); - const reasoningDraftStream = createDraftStream(111); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce((params) => { - reasoningDraftParams = params as typeof reasoningDraftParams; - return reasoningDraftStream; - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_first block_" }); - await replyOptions?.onReasoningEnd?.(); - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_second block_" }); - reasoningDraftParams?.onSupersededPreview?.({ - messageId: 4444, - textSnapshot: "Reasoning:\n_first block_", - }); - await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - const bot = createBot(); - await dispatchWithContext({ - context: createReasoningStreamContext(), - streamMode: "partial", - bot, - }); - - expect(reasoningDraftParams?.onSupersededPreview).toBeTypeOf("function"); - const deleteMessageCalls = ( - bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } } - ).deleteMessage.mock.calls; - expect(deleteMessageCalls).toContainEqual([123, 4444]); - }); - - it("does not split reasoning lane on reasoning end without a later reasoning block", async () => { - const { reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_first block_" }); - await replyOptions?.onReasoningEnd?.(); - await replyOptions?.onPartialReply?.({ text: "Here's the answer" }); - await dispatcherOptions.deliver({ text: "Here's the answer" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.forceNewMessage).not.toHaveBeenCalled(); - }); - - it("suppresses reasoning-only final payloads when reasoning level is off", async () => { - setupDraftStreams({ answerMessageId: 999 }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Hi, I did what you asked and..." }); - await dispatcherOptions.deliver({ text: "Reasoning:\n_step one_" }, { kind: "final" }); - await dispatcherOptions.deliver( - { text: "Hi, I did what you asked and..." }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(deliverReplies).not.toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Reasoning:\n_step one_" })], - }), - ); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "Hi, I did what you asked and...", - expect.any(Object), - ); - }); - - it("does not resend suppressed reasoning-only text through raw fallback", async () => { - setupDraftStreams({ answerMessageId: 999 }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "Reasoning:\n_step one_" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(deliverReplies).not.toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Reasoning:\n_step one_" })], - }), - ); - expect(editMessageTelegram).not.toHaveBeenCalled(); - }); - - it.each([undefined, null] as const)( - "skips outbound send when final payload text is %s and has no media", - async (emptyText) => { - const { answerDraftStream } = setupDraftStreams({ answerMessageId: 999 }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver( - { text: emptyText as unknown as string }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(answerDraftStream.clear).toHaveBeenCalledTimes(1); - }, - ); - - it("uses message preview transport for all DM lanes when streaming is active", async () => { - setupDraftStreams({ answerMessageId: 999, reasoningMessageId: 111 }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_Working on it..._" }); - await replyOptions?.onPartialReply?.({ text: "Checking the directory..." }); - await dispatcherOptions.deliver({ text: "Checking the directory..." }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(createTelegramDraftStream).toHaveBeenCalledTimes(2); - expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - thread: { id: 777, scope: "dm" }, - }), - ); - expect(createTelegramDraftStream.mock.calls[1]?.[0]).toEqual( - expect.objectContaining({ - thread: { id: 777, scope: "dm" }, - }), - ); - }); - - it("finalizes DM answer preview in place without materializing or sending a duplicate", async () => { - const answerDraftStream = createDraftStream(321); - const reasoningDraftStream = createDraftStream(111); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Checking the directory..." }); - await dispatcherOptions.deliver({ text: "Checking the directory..." }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - - expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - thread: { id: 777, scope: "dm" }, - }), - ); - expect(answerDraftStream.materialize).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 321, - "Checking the directory...", - expect.any(Object), - ); - }); - - it("keeps reasoning and answer streaming in separate preview lanes", async () => { + it("streams reasoning and answer text on separate lanes", async () => { const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, + answerMessageId: 2001, + reasoningMessageId: 3001, }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_Working on it..._" }); - await replyOptions?.onPartialReply?.({ text: "Checking the directory..." }); - await dispatcherOptions.deliver({ text: "Checking the directory..." }, { kind: "final" }); + await replyOptions?.onReasoningStream?.({ text: "Thinking" }); + await dispatcherOptions.deliver({ text: "Answer" }, { kind: "final" }); return { queuedFinal: true }; }, ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext() }); - expect(reasoningDraftStream.update).toHaveBeenCalledWith("Reasoning:\n_Working on it..._"); - expect(answerDraftStream.update).toHaveBeenCalledWith("Checking the directory..."); - expect(answerDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(reasoningDraftStream.forceNewMessage).not.toHaveBeenCalled(); - }); - - it("does not edit reasoning preview bubble with final answer when no assistant partial arrived yet", async () => { - setupDraftStreams({ reasoningMessageId: 999 }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_Working on it..._" }); - await dispatcherOptions.deliver({ text: "Here's what I found." }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Here's what I found." })], - }), - ); - }); - - it("does not duplicate reasoning final after reasoning end", async () => { - let reasoningMessageId: number | undefined = 111; - const reasoningDraftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(undefined), - messageId: vi.fn().mockImplementation(() => reasoningMessageId), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn().mockImplementation(() => { - reasoningMessageId = undefined; - }), - }; - const answerDraftStream = createDraftStream(999); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_step one_" }); - await replyOptions?.onReasoningEnd?.(); - await dispatcherOptions.deliver( - { text: "Reasoning:\n_step one expanded_" }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "111" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.forceNewMessage).not.toHaveBeenCalled(); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 111, - "Reasoning:\n_step one expanded_", - expect.any(Object), - ); + expect(reasoningDraftStream.update).toHaveBeenCalledWith("Reasoning:\n_Thinking_"); + expect(answerDraftStream.update).toHaveBeenCalledWith("Answer"); expect(deliverReplies).not.toHaveBeenCalled(); }); - it("updates reasoning preview for reasoning block payloads instead of sending duplicates", async () => { - setupDraftStreams({ answerMessageId: 999, reasoningMessageId: 111 }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ - text: "Reasoning:\nIf I count r in strawberry, I see positions 3, 8, and", - }); - await replyOptions?.onReasoningEnd?.(); - await replyOptions?.onPartialReply?.({ text: "3" }); - await dispatcherOptions.deliver({ text: "3" }, { kind: "final" }); - await dispatcherOptions.deliver( - { - text: "Reasoning:\nIf I count r in strawberry, I see positions 3, 8, and 9. So the total is 3.", - }, - { kind: "block" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(editMessageTelegram).toHaveBeenNthCalledWith(1, 123, 999, "3", expect.any(Object)); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 111, - "Reasoning:\nIf I count r in strawberry, I see positions 3, 8, and 9. So the total is 3.", - expect.any(Object), - ); - expect(deliverReplies).not.toHaveBeenCalledWith( - expect.objectContaining({ - replies: [ - expect.objectContaining({ - text: expect.stringContaining("Reasoning:\nIf I count r in strawberry"), - }), - ], - }), - ); - }); - - it("keeps DM reasoning block updates in preview flow without sending duplicates", async () => { - const answerDraftStream = createDraftStream(999); - let previewRevision = 0; - const reasoningDraftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(true), - messageId: vi.fn().mockReturnValue(111), - previewRevision: vi.fn().mockImplementation(() => previewRevision), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn(), - }; - reasoningDraftStream.update.mockImplementation(() => { - previewRevision += 1; - }); - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ - text: "Reasoning:\nI am counting letters...", - }); - await replyOptions?.onReasoningEnd?.(); - await replyOptions?.onPartialReply?.({ text: "3" }); - await dispatcherOptions.deliver({ text: "3" }, { kind: "final" }); - await dispatcherOptions.deliver( - { - text: "Reasoning:\nI am counting letters. The total is 3.", - }, - { kind: "block" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "3", expect.any(Object)); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 111, - "Reasoning:\nI am counting letters. The total is 3.", - expect.any(Object), - ); - expect(reasoningDraftStream.update).toHaveBeenCalledWith( - "Reasoning:\nI am counting letters...", - ); - expect(reasoningDraftStream.flush).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: expect.stringContaining("Reasoning:\nI am") })], - }), - ); - }); - - it("falls back to normal send when DM reasoning preview has no message id", async () => { - const answerDraftStream = createDraftStream(999); - const previewRevision = 0; - const reasoningDraftStream = { - update: vi.fn(), - flush: vi.fn().mockResolvedValue(false), - messageId: vi.fn().mockReturnValue(undefined), - previewRevision: vi.fn().mockReturnValue(previewRevision), - clear: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - forceNewMessage: vi.fn(), - }; - createTelegramDraftStream - .mockImplementationOnce(() => answerDraftStream) - .mockImplementationOnce(() => reasoningDraftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_step one_" }); - await replyOptions?.onReasoningEnd?.(); - await dispatcherOptions.deliver( - { text: "Reasoning:\n_step one expanded_" }, - { kind: "block" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.flush).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Reasoning:\n_step one expanded_" })], - }), - ); - }); - - it("routes think-tag partials to reasoning lane and keeps answer lane clean", async () => { - const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ - text: "Counting letters in strawberry3", - }); - await dispatcherOptions.deliver({ text: "3" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.update).toHaveBeenCalledWith( - "Reasoning:\n_Counting letters in strawberry_", - ); - expect(answerDraftStream.update).toHaveBeenCalledWith("3"); - expect( - answerDraftStream.update.mock.calls.some((call) => (call[0] ?? "").includes("")), - ).toBe(false); - expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "3", expect.any(Object)); - }); - - it("routes unmatched think partials to reasoning lane without leaking answer lane", async () => { - const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ - text: "Counting letters in strawberry", - }); - await dispatcherOptions.deliver( - { text: "There are 3 r's in strawberry." }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.update).toHaveBeenCalledWith( - "Reasoning:\n_Counting letters in strawberry_", - ); - expect(answerDraftStream.update.mock.calls.some((call) => (call[0] ?? "").includes("<"))).toBe( - false, - ); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "There are 3 r's in strawberry.", - expect.any(Object), - ); - }); - - it("clears reasoning preview message when reasoning is streamed but final is answer-only", async () => { - const { reasoningDraftStream } = setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ - text: "Word: strawberry. r appears at 3, 8, 9.", - }); - await dispatcherOptions.deliver( - { text: "There are 3 r's in strawberry." }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); - - expect(reasoningDraftStream.update).toHaveBeenCalledWith( - "Reasoning:\n_Word: strawberry. r appears at 3, 8, 9._", - ); - expect(reasoningDraftStream.clear).toHaveBeenCalledTimes(1); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "There are 3 r's in strawberry.", - expect.any(Object), - ); - }); - - it("splits think-tag final payload into reasoning and answer lanes", async () => { - setupDraftStreams({ - answerMessageId: 999, - reasoningMessageId: 111, - }); + it("suppresses reasoning-only finals without raw text fallback", async () => { + setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver( - { - text: "Word: strawberry. r appears at 3, 8, 9.There are 3 r's in strawberry.", - }, - { kind: "final" }, - ); + await dispatcherOptions.deliver({ text: "hidden" }, { kind: "final" }); return { queuedFinal: true }; }); - deliverReplies.mockResolvedValue({ delivered: true }); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createContext() }); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 1, - 123, - 111, - "Reasoning:\n_Word: strawberry. r appears at 3, 8, 9._", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 999, - "There are 3 r's in strawberry.", - 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); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - // Partial text output - await replyOptions?.onPartialReply?.({ text: "Let me check that file" }); - // Error payload should not edit the preview message - await dispatcherOptions.deliver( - { text: "āš ļø šŸ› ļø Exec: cat /nonexistent failed: No such file", isError: true }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "block" }); - - // Should NOT edit preview message (which would overwrite the partial text) expect(editMessageTelegram).not.toHaveBeenCalled(); - // Should deliver via normal path as a new message - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: expect.stringContaining("āš ļø") })], - }), - ); - }); - - it("finalizes explicit failed-action replies without a standalone warning delivery", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Let me update that file." }); - await dispatcherOptions.deliver( - { text: "I couldn't update the file, so no changes were applied." }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "block" }); - - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 999, - "I couldn't update the file, so no changes were applied.", - expect.any(Object), - ); - expect(deliverReplies).not.toHaveBeenCalled(); - expect(draftStream.clear).not.toHaveBeenCalled(); - }); - - it("clears preview for error-only finals", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "tool failed", isError: true }, { kind: "final" }); - await dispatcherOptions.deliver({ text: "another error", isError: true }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - // Error payloads skip preview finalization — preview must be cleaned up - expect(draftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("clears preview after media final delivery", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/a.png" }, { kind: "final" }); - return { queuedFinal: true }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - expect(draftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("clears stale preview when response is NO_REPLY", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ - queuedFinal: false, - }); - - await dispatchWithContext({ context: createContext() }); - - // Preview contains stale partial text — must be cleaned up - expect(draftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("falls back when all finals are skipped and clears preview", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - dispatcherOptions.onSkip?.({ text: "" }, { reason: "empty", kind: "final" }); - return { queuedFinal: false }; - }); - deliverReplies.mockResolvedValueOnce({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [ - expect.objectContaining({ - text: expect.stringContaining("No response"), - }), - ], - }), - ); - expect(draftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("rewrites a no-visible-response DM turn through silent-reply fallback", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ - queuedFinal: false, - }); - deliverReplies.mockResolvedValueOnce({ delivered: true }); - - await dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "agent:main:telegram:direct:123", - } as unknown as TelegramMessageContext["ctxPayload"], - }), - cfg: { - agents: { - defaults: { - silentReply: { - direct: "disallow", - group: "allow", - internal: "allow", - }, - silentReplyRewrite: { - direct: true, - }, - }, - }, - } as unknown as OpenClawConfig, - }); - - expect(deliverReplies).toHaveBeenCalledTimes(1); - const deliveredReplies = deliverReplies.mock.calls[0]?.[0]?.replies; - expect(Array.isArray(deliveredReplies)).toBe(true); - expect(deliveredReplies?.[0]?.text).toEqual(expect.any(String)); - expect(deliveredReplies?.[0]?.text?.trim()).not.toBe("NO_REPLY"); - }); - - it("does not add silent-reply fallback for message-tool-only turns", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ - queuedFinal: false, - counts: { tool: 0, block: 0, final: 0 }, - sourceReplyDeliveryMode: "message_tool_only", - }); - - await dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "agent:main:telegram:direct:123", - } as unknown as TelegramMessageContext["ctxPayload"], - }), - cfg: { - agents: { - defaults: { - silentReply: { - direct: "disallow", - group: "allow", - internal: "allow", - }, - silentReplyRewrite: { - direct: true, - }, - }, - }, - } as unknown as OpenClawConfig, - }); - - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("falls back in forum topics when a queued final was not delivered to Telegram", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ - queuedFinal: true, - counts: { tool: 0, block: 0, final: 1 }, - }); - deliverReplies.mockResolvedValueOnce({ delivered: true }); - - await dispatchWithContext({ - context: createContext({ - isGroup: true, - chatId: -1003752586071, - primaryCtx: { - message: { chat: { id: -1003752586071, type: "supergroup" } }, - } as TelegramMessageContext["primaryCtx"], - msg: { - chat: { id: -1003752586071, type: "supergroup" }, - message_id: 3, - message_thread_id: 2, - is_topic_message: true, - } as TelegramMessageContext["msg"], - threadSpec: { id: 2, scope: "forum" }, - ctxPayload: { - SessionKey: "agent:main:telegram:group:-1003752586071:topic:2", - MessageThreadId: 2, - IsForum: true, - } as unknown as TelegramMessageContext["ctxPayload"], - }), - cfg: { - agents: { - defaults: { - silentReply: { - direct: "disallow", - group: "disallow", - internal: "allow", - }, - silentReplyRewrite: { - group: false, - }, - }, - }, - } as unknown as OpenClawConfig, - }); - - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - chatId: "-1003752586071", - thread: { id: 2, scope: "forum" }, - replies: [expect.objectContaining({ text: "NO_REPLY" })], - }), - ); - }); - - it("does not add silent-reply fallback after visible block delivery", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "visible block" }, { kind: "block" }); - return { queuedFinal: false }; - }); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "agent:main:telegram:direct:123", - } as unknown as TelegramMessageContext["ctxPayload"], - }), - cfg: { - agents: { - defaults: { - silentReply: { - direct: "disallow", - group: "allow", - internal: "allow", - }, - silentReplyRewrite: { - direct: true, - }, - }, - }, - } as unknown as OpenClawConfig, - }); - - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "visible block" })], - }), - ); - }); - - it("keeps no-visible-response group turns silent when policy allows silence", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ - queuedFinal: false, - }); - - await dispatchWithContext({ - context: createContext({ - isGroup: true, - primaryCtx: { - message: { chat: { id: 123, type: "supergroup" } }, - } as TelegramMessageContext["primaryCtx"], - msg: { - chat: { id: 123, type: "supergroup" }, - message_id: 456, - message_thread_id: 777, - } as TelegramMessageContext["msg"], - threadSpec: { id: 777, scope: "forum" }, - ctxPayload: { - SessionKey: "agent:main:telegram:group:123", - } as unknown as TelegramMessageContext["ctxPayload"], - }), - cfg: { - agents: { - defaults: { - silentReply: { - direct: "disallow", - group: "allow", - internal: "allow", - }, - silentReplyRewrite: { - direct: true, - }, - }, - }, - } as unknown as OpenClawConfig, - }); - - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("sends fallback and clears preview when deliver throws (dispatcher swallows error)", async () => { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - try { - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - } catch (err) { - dispatcherOptions.onError?.(err, { kind: "final" }); - } - return { queuedFinal: false }; - }); - deliverReplies - .mockRejectedValueOnce(new Error("network down")) - .mockResolvedValueOnce({ delivered: true }); - - await expect(dispatchWithContext({ context: createContext() })).resolves.toBeUndefined(); - // Fallback should be sent because failedDeliveries > 0 - expect(deliverReplies).toHaveBeenCalledTimes(2); - expect(deliverReplies).toHaveBeenLastCalledWith( - expect.objectContaining({ - replies: [ - expect.objectContaining({ - text: expect.stringContaining("No response"), - }), - ], - }), - ); - expect(draftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("sends fallback in off mode when deliver throws", async () => { - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - try { - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - } catch (err) { - dispatcherOptions.onError?.(err, { kind: "final" }); - } - return { queuedFinal: false }; - }); - deliverReplies - .mockRejectedValueOnce(new Error("403 bot blocked")) - .mockResolvedValueOnce({ delivered: true }); - - await dispatchWithContext({ context: createContext(), streamMode: "off" }); - - expect(createTelegramDraftStream).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenCalledTimes(2); - expect(deliverReplies).toHaveBeenLastCalledWith( - expect.objectContaining({ - replies: [ - expect.objectContaining({ - text: expect.stringContaining("No response"), - }), - ], - }), - ); - }); - - it("sends a fresh final after a visible error block bubble pushes the preview up", async () => { - const draftStream = createTestDraftStream({ - messageId: 999, - visibleSinceMs: Date.now() - 1_000, - }); - createTelegramDraftStream.mockReturnValue(draftStream); - editMessageTelegram.mockResolvedValue({ ok: true }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Processing..." }); - await dispatcherOptions.deliver( - { text: "āš ļø exec failed", isError: true }, - { kind: "block" }, - ); - await dispatcherOptions.deliver( - { text: "The command timed out. Here's what I found..." }, - { kind: "final" }, - ); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - // Error block + fresh final both went through deliverReplies; preview was - // not edited in place and the stale preview was cleared. - expect(deliverReplies).toHaveBeenCalledTimes(2); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenLastCalledWith( - expect.objectContaining({ - replies: [ - expect.objectContaining({ text: "The command timed out. Here's what I found..." }), - ], - }), - ); - expect(draftStream.clear).toHaveBeenCalled(); - }); - - // #76529: when a visible non-final message is delivered after the answer - // preview is already on screen, finalizing the preview by edit puts the - // final answer above the intermediate output. Force a fresh send instead. - it("sends a fresh final after a visible block bubble pushes the preview up (#76529)", async () => { - // Preview was already on screen before the block bubble was sent. - const draftStream = createTestDraftStream({ - messageId: 999, - visibleSinceMs: Date.now() - 1_000, - }); - createTelegramDraftStream.mockReturnValue(draftStream); - editMessageTelegram.mockResolvedValue({ ok: true }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Checked maxDBdays..." }); - await dispatcherOptions.deliver( - { text: "Changed maxDBdays from 91 → 14" }, - { kind: "block" }, - ); - await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - // Block + fresh final both went through deliverReplies; preview was not - // edited in place and the stale preview was cleared. - expect(deliverReplies).toHaveBeenCalledTimes(2); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).toHaveBeenLastCalledWith( - expect.objectContaining({ - replies: [expect.objectContaining({ text: "Done" })], - }), - ); - expect(draftStream.clear).toHaveBeenCalled(); - }); - - it("cleans up preview even when fallback delivery throws (double failure)", async () => { - const draftStream = createDraftStream(); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { - try { - await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); - } catch (err) { - dispatcherOptions.onError?.(err, { kind: "final" }); - } - return { queuedFinal: false }; - }); - // No preview message id → deliver goes through deliverReplies directly - // Primary delivery fails - deliverReplies - .mockRejectedValueOnce(new Error("network down")) - // Fallback also fails - .mockRejectedValueOnce(new Error("still down")); - - // Fallback throws, but cleanup still runs via try/finally. - await dispatchWithContext({ context: createContext() }).catch(() => {}); - - // Verify fallback was attempted and preview still cleaned up - expect(deliverReplies).toHaveBeenCalledTimes(2); - expect(draftStream.clear).toHaveBeenCalledTimes(1); - }); - - it("sends error fallback and clears preview when dispatcher throws", async () => { - const draftStream = createDraftStream(999); - createTelegramDraftStream.mockReturnValue(draftStream); - dispatchReplyWithBufferedBlockDispatcher.mockRejectedValue(new Error("dispatcher exploded")); - deliverReplies.mockResolvedValue({ delivered: true }); - - await dispatchWithContext({ context: createContext() }); - - expect(draftStream.stop).toHaveBeenCalledTimes(1); - expect(draftStream.clear).toHaveBeenCalledTimes(1); - // Error fallback message should be delivered to the user instead of silent failure - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [ - { text: "Something went wrong while processing your request. Please try again." }, - ], - }), - ); - }); - - it("supports concurrent dispatches with independent previews", async () => { - const draftA = createDraftStream(11); - const draftB = createDraftStream(22); - createTelegramDraftStream.mockReturnValueOnce(draftA).mockReturnValueOnce(draftB); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "partial" }); - await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/a.png" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - deliverReplies.mockResolvedValue({ delivered: true }); - - await Promise.all([ - dispatchWithContext({ - context: createContext({ - chatId: 1, - msg: { chat: { id: 1, type: "private" }, message_id: 1 } as never, - }), - }), - dispatchWithContext({ - context: createContext({ - chatId: 2, - msg: { chat: { id: 2, type: "private" }, message_id: 2 } as never, - }), - }), - ]); - - expect(draftA.clear).toHaveBeenCalledTimes(1); - expect(draftB.clear).toHaveBeenCalledTimes(1); - }); - - it("ignores stale answer finalization after an abort dispatch supersedes the same session", async () => { - let releaseFirstFinal!: () => void; - const firstFinalGate = new Promise((resolve) => { - releaseFirstFinal = resolve; - }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; - }); - - const firstAnswerDraft = createTestDraftStream({ - messageId: 1001, - onUpdate: (text) => { - if (text === "Old reply partial") { - resolvePreviewVisible(); - } - }, - }); - const firstReasoningDraft = createDraftStream(); - const abortAnswerDraft = createDraftStream(); - const abortReasoningDraft = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => abortAnswerDraft) - .mockImplementationOnce(() => abortReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Old reply partial" }); - await firstFinalGate; - await dispatcherOptions.deliver({ text: "Old reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - }); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - } as never, - }), - }); - - await previewVisible; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - }); - - await abortReplyDelivered; - - releaseFirstFinal(); - await Promise.all([firstPromise, abortPromise]); - - expect(editMessageTelegram).not.toHaveBeenCalledWith( - 123, - 1001, - "Old reply final", - expect.any(Object), - ); - expect(firstAnswerDraft.clear).not.toHaveBeenCalled(); - }); - - it("ignores stale answer finalization after a newer message supersedes the same session", async () => { - let releaseFirstFinal!: () => void; - const firstFinalGate = new Promise((resolve) => { - releaseFirstFinal = resolve; - }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; - }); - - const firstAnswerDraft = createTestDraftStream({ - messageId: 1001, - onUpdate: (text) => { - if (text === "Old reply partial") { - resolvePreviewVisible(); - } - }, - }); - const firstReasoningDraft = createDraftStream(); - const secondAnswerDraft = createDraftStream(); - const secondReasoningDraft = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => secondAnswerDraft) - .mockImplementationOnce(() => secondReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Old reply partial" }); - await firstFinalGate; - await dispatcherOptions.deliver({ text: "Old reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "New reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }); - const newReplyDelivered = observeDeliveredReply("New reply final"); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - MessageSid: "msg-1", - } as never, - }), - }); - - await previewVisible; - - const secondPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "newer request", - RawBody: "newer request", - MessageSid: "msg-2", - } as never, - }), - }); - - await newReplyDelivered; - - releaseFirstFinal(); - await Promise.all([firstPromise, secondPromise]); - - expect(editMessageTelegram).not.toHaveBeenCalledWith( - 123, - 1001, - "Old reply final", - expect.any(Object), - ); - expect(firstAnswerDraft.clear).not.toHaveBeenCalled(); - }); - - it("discards hidden short partials instead of flushing a stale preview after abort", async () => { - let releaseFirstCleanup!: () => void; - const firstCleanupGate = new Promise((resolve) => { - releaseFirstCleanup = resolve; - }); - let resolveShortPartialQueued!: () => void; - const shortPartialQueued = new Promise((resolve) => { - resolveShortPartialQueued = resolve; - }); - - const firstAnswerDraft = createTestDraftStream({ - onUpdate: (text) => { - if (text === "tiny") { - resolveShortPartialQueued(); - } - }, - onStop: () => { - throw new Error("superseded cleanup should discard instead of stop"); - }, - }); - const firstReasoningDraft = createDraftStream(); - const abortAnswerDraft = createDraftStream(); - const abortReasoningDraft = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => abortAnswerDraft) - .mockImplementationOnce(() => abortReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "tiny" }); - await firstCleanupGate; - return { queuedFinal: false }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - }); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - } as never, - }), - }); - - await shortPartialQueued; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - }); - - await abortReplyDelivered; - - releaseFirstCleanup(); - await Promise.all([firstPromise, abortPromise]); - - expect(firstAnswerDraft.discard).toHaveBeenCalledTimes(1); - expect(firstAnswerDraft.stop).not.toHaveBeenCalled(); - expect(firstAnswerDraft.clear).not.toHaveBeenCalled(); - }); - - it("suppresses stale replies when abort lands during async pre-dispatch work", async () => { - let releaseCatalogLoad!: () => void; - const catalogLoadGate = new Promise>((resolve) => { - releaseCatalogLoad = () => resolve({}); - }); - let resolveCatalogLoadStarted!: () => void; - const catalogLoadStarted = new Promise((resolve) => { - resolveCatalogLoadStarted = resolve; - }); - - loadModelCatalog.mockImplementationOnce(async () => { - resolveCatalogLoadStarted(); - return await catalogLoadGate; - }); - dispatchReplyWithBufferedBlockDispatcher.mockImplementation( - async ({ ctx, dispatcherOptions }) => { - if (ctx.CommandBody === "abort") { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - } - await dispatcherOptions.deliver({ text: "Old reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }, - ); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - MediaPath: "/tmp/sticker.png", - Sticker: { - fileId: "file-id", - fileUniqueId: "file-unique-id", - }, - } as never, - }), - }); - - await catalogLoadStarted; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - }); - - await abortReplyDelivered; - - releaseCatalogLoad(); - await Promise.all([firstPromise, abortPromise]); - - expect(deliverReplies).not.toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "Old reply final" }], - }), - ); - }); - - it("releases the abort fence when pre-dispatch setup throws", async () => { - describeStickerImage.mockRejectedValueOnce(new Error("sticker setup failed")); - - await expect( - dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - MediaPath: "/tmp/sticker.png", - Sticker: { - fileId: "file-id", - fileUniqueId: "file-unique-id", - }, - } as never, - }), - }), - ).rejects.toThrow("sticker setup failed"); - - expect(getTelegramReplyFenceSizeForTests()).toBe(0); - }); - - it("keeps older answer finalization when abort targets a different session", async () => { - let releaseFirstFinal!: () => void; - const firstFinalGate = new Promise((resolve) => { - releaseFirstFinal = resolve; - }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; - }); - - const firstAnswerDraft = createTestDraftStream({ - messageId: 1001, - onUpdate: (text) => { - if (text === "Old reply partial") { - resolvePreviewVisible(); - } - }, - }); - const firstReasoningDraft = createDraftStream(); - const abortAnswerDraft = createDraftStream(); - const abortReasoningDraft = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => abortAnswerDraft) - .mockImplementationOnce(() => abortReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Old reply partial" }); - await firstFinalGate; - await dispatcherOptions.deliver({ text: "Old reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - }); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - } as never, - }), - }); - - await previewVisible; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s2", - CommandTargetSessionKey: "s2", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - }); - - await abortReplyDelivered; - - releaseFirstFinal(); - await Promise.all([firstPromise, abortPromise]); - - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Old reply final", - expect.any(Object), - ); - }); - - it("finalizes stale status reactions when an abort supersedes the same session", async () => { - let releaseFirstFinal!: () => void; - const firstFinalGate = new Promise((resolve) => { - releaseFirstFinal = resolve; - }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; - }); - - const reactionApi = vi.fn(async () => true); - const statusReactionController = createStatusReactionController(); - const firstAnswerDraft = createTestDraftStream({ - messageId: 1001, - onUpdate: (text) => { - if (text === "Old reply partial") { - resolvePreviewVisible(); - } - }, - }); - const firstReasoningDraft = createDraftStream(); - const abortAnswerDraft = createDraftStream(); - const abortReasoningDraft = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => abortAnswerDraft) - .mockImplementationOnce(() => abortReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Old reply partial" }); - await firstFinalGate; - await dispatcherOptions.deliver({ text: "Old reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - }); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - const firstPromise = dispatchWithContext({ - context: createContext({ - reactionApi: reactionApi as never, - removeAckAfterReply: true, - statusReactionController: statusReactionController as never, - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - } as never, - }), - cfg: { - messages: { - statusReactions: { - timing: { - doneHoldMs: 250, - }, - }, - }, - }, - }); - - await previewVisible; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - }); - - await abortReplyDelivered; - - vi.useFakeTimers(); - try { - releaseFirstFinal(); - await Promise.all([firstPromise, abortPromise]); - - expect(statusReactionController.setDone).toHaveBeenCalledTimes(1); - expect(statusReactionController.setError).not.toHaveBeenCalled(); - expect(reactionApi).not.toHaveBeenCalledWith(123, 456, []); - - await vi.advanceTimersByTimeAsync(249); - expect(reactionApi).not.toHaveBeenCalledWith(123, 456, []); - - await vi.advanceTimersByTimeAsync(1); - expect(reactionApi).toHaveBeenCalledWith(123, 456, []); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps an existing preview when abort arrives during queued draft-lane cleanup", async () => { - let releaseMaterialize!: () => void; - const materializeGate = new Promise((resolve) => { - releaseMaterialize = resolve; - }); - let resolveMaterializeStarted!: () => void; - const materializeStarted = new Promise((resolve) => { - resolveMaterializeStarted = resolve; - }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; - }); - - const firstAnswerDraft = createTestDraftStream({ - messageId: 1001, - clearMessageIdOnForceNew: true, - onUpdate: (text) => { - if (text === "Old reply partial") { - resolvePreviewVisible(); - } - }, - }); - firstAnswerDraft.materialize.mockImplementation(async () => { - resolveMaterializeStarted(); - await materializeGate; - return 1001; - }); - const firstReasoningDraft = createDraftStream(); - const abortAnswerDraft = createDraftStream(); - const abortReasoningDraft = createDraftStream(); - const bot = createBot(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => abortAnswerDraft) - .mockImplementationOnce(() => abortReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Old reply partial" }); - void replyOptions?.onAssistantMessageStart?.(); - return { queuedFinal: false }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - }); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - } as never, - }), - bot, - }); - - await previewVisible; - await materializeStarted; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - bot, - }); - - await abortReplyDelivered; - - releaseMaterialize(); - await Promise.all([firstPromise, abortPromise]); - - expect(firstAnswerDraft.clear).not.toHaveBeenCalled(); - expect(bot.api.deleteMessage as ReturnType).not.toHaveBeenCalledWith(123, 1001); - }); - - it("ignores stale answer finalization when abort targets the session via CommandTargetSessionKey", async () => { - let releaseFirstFinal!: () => void; - const firstFinalGate = new Promise((resolve) => { - releaseFirstFinal = resolve; - }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; - }); - - const firstAnswerDraft = createTestDraftStream({ - messageId: 1001, - onUpdate: (text) => { - if (text === "Old reply partial") { - resolvePreviewVisible(); - } - }, - }); - const firstReasoningDraft = createDraftStream(); - const abortAnswerDraft = createDraftStream(); - const abortReasoningDraft = createDraftStream(); - createTelegramDraftStream - .mockImplementationOnce(() => firstAnswerDraft) - .mockImplementationOnce(() => firstReasoningDraft) - .mockImplementationOnce(() => abortAnswerDraft) - .mockImplementationOnce(() => abortReasoningDraft); - dispatchReplyWithBufferedBlockDispatcher - .mockImplementationOnce(async ({ dispatcherOptions, replyOptions }) => { - await replyOptions?.onPartialReply?.({ text: "Old reply partial" }); - await firstFinalGate; - await dispatcherOptions.deliver({ text: "Old reply final" }, { kind: "final" }); - return { queuedFinal: true }; - }) - .mockImplementationOnce(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "āš™ļø Agent was aborted." }, { kind: "final" }); - return { queuedFinal: true }; - }); - const abortReplyDelivered = observeDeliveredReply("āš™ļø Agent was aborted."); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - - const firstPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "s1", - Body: "earlier request", - RawBody: "earlier request", - } as never, - }), - }); - - await previewVisible; - - const abortPromise = dispatchWithContext({ - context: createContext({ - ctxPayload: { - SessionKey: "telegram:123:control", - CommandTargetSessionKey: "s1", - Body: "abort", - RawBody: "abort", - CommandBody: "abort", - CommandAuthorized: true, - } as never, - }), - }); - - await abortReplyDelivered; - - releaseFirstFinal(); - await Promise.all([firstPromise, abortPromise]); - - expect(editMessageTelegram).not.toHaveBeenCalledWith( - 123, - 1001, - "Old reply final", - expect.any(Object), - ); - expect(firstAnswerDraft.clear).not.toHaveBeenCalled(); - }); - - it("swallows post-connect network timeout on preview edit to prevent duplicate messages", 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 }); - // Simulate a post-connect timeout: editMessageTelegram throws a network - // error even though Telegram's server already processed the edit. - editMessageTelegram.mockRejectedValue(new Error("timeout: request timed out after 30000ms")); - - 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( - (r: { text?: string }) => r.text === "Final answer", - ), - ); - expect(finalTextSentViaDeliverReplies).toBe(false); - }); - - it("falls back to sendPayload on pre-connect error during final edit", 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 }); - deliverInboundReplyWithMessageSendContext.mockResolvedValue({ - status: "handled_visible", - delivery: { - messageIds: ["2002"], - visibleReplySent: true, - }, - }); - const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443"); - (preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED"; - editMessageTelegram.mockRejectedValue(preConnectErr); - - await dispatchWithContext({ context: createContext() }); - - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "telegram", - to: "123", - accountId: "default", - agentId: "default", - payload: expect.objectContaining({ text: "Final answer" }), - info: { kind: "final" }, - replyToMode: "first", - threadId: 777, - formatting: expect.objectContaining({ textLimit: 4096, tableMode: "preserve" }), - }), - ); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("falls back when Telegram reports the current 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( - (r: { text?: string }) => r.text === "Final answer", - ), - ); - expect(finalTextSentViaDeliverReplies).toBe(true); }); it("shows compacting reaction during auto-compaction and resumes thinking", async () => { @@ -4564,16 +1077,16 @@ describe("dispatchTelegramMessage draft streaming", () => { const firstFinalGate = new Promise((resolve) => { releaseFirstFinal = resolve; }); - let resolvePreviewVisible!: () => void; - const previewVisible = new Promise((resolve) => { - resolvePreviewVisible = resolve; + let resolveStreamVisible!: () => void; + const streamVisible = new Promise((resolve) => { + resolveStreamVisible = resolve; }); const firstAnswerDraft = createTestDraftStream({ messageId: 1001, onUpdate: (text) => { if (text === "Old reply partial") { - resolvePreviewVisible(); + resolveStreamVisible(); } }, }); @@ -4597,8 +1110,6 @@ describe("dispatchTelegramMessage draft streaming", () => { return { queuedFinal: true }; }); const unauthorizedReplyDelivered = observeDeliveredReply("Unauthorized stop"); - editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); - const firstPromise = dispatchWithContext({ context: createContext({ ctxPayload: { @@ -4609,7 +1120,7 @@ describe("dispatchTelegramMessage draft streaming", () => { }), }); - await previewVisible; + await streamVisible; const unauthorizedPromise = dispatchWithContext({ context: createContext({ @@ -4628,12 +1139,8 @@ describe("dispatchTelegramMessage draft streaming", () => { releaseFirstFinal(); await Promise.all([firstPromise, unauthorizedPromise]); - expect(editMessageTelegram).toHaveBeenCalledWith( - 123, - 1001, - "Old reply final", - expect.any(Object), - ); + expect(firstAnswerDraft.update).toHaveBeenCalledWith("Old reply final"); + expect(editMessageTelegram).not.toHaveBeenCalled(); }); it("uses configured doneHoldMs when clearing Telegram status reactions after reply", async () => { diff --git a/extensions/telegram/src/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts index 3eb63ce1933..01409431862 100644 --- a/extensions/telegram/src/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -1,11 +1,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { describe, expect, it, vi } from "vitest"; +import { createTestDraftStream } from "./draft-stream.test-helpers.js"; import { - createSequencedTestDraftStream, - createTestDraftStream, -} from "./draft-stream.test-helpers.js"; -import { - type ArchivedPreview, createLaneTextDeliverer, type DraftLaneState, type LaneDeliveryResult, @@ -16,32 +12,27 @@ const HELLO_FINAL = "Hello final"; function createHarness(params?: { answerMessageId?: number; + answerStream?: DraftLaneState["stream"] | null; draftMaxChars?: number; - answerMessageIdAfterStop?: number; - answerStream?: DraftLaneState["stream"]; - answerHasStreamedMessage?: boolean; - answerLastPartialText?: string; - answerPreviewVisibleSinceMs?: number; - splitFinalTextForPreview?: (text: string) => readonly string[]; - nowMs?: number; + splitFinalTextForStream?: (text: string) => readonly string[]; }) { const answer = - params?.answerStream ?? - createTestDraftStream({ - messageId: params?.answerMessageId, - visibleSinceMs: params?.answerPreviewVisibleSinceMs, - }); + params?.answerStream === null + ? undefined + : (params?.answerStream ?? createTestDraftStream({ messageId: params?.answerMessageId })); const reasoning = createTestDraftStream(); const lanes: Record = { answer: { stream: answer, - lastPartialText: params?.answerLastPartialText ?? "", - hasStreamedMessage: params?.answerHasStreamedMessage ?? false, - }, - reasoning: { - stream: reasoning as DraftLaneState["stream"], lastPartialText: "", hasStreamedMessage: false, + finalized: false, + }, + reasoning: { + stream: reasoning, + lastPartialText: "", + hasStreamedMessage: false, + finalized: false, }, }; const sendPayload = vi.fn().mockResolvedValue(true); @@ -49,54 +40,41 @@ function createHarness(params?: { await lane.stream?.flush(); }); const stopDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => { - if (lane === lanes.answer && params?.answerMessageIdAfterStop !== undefined) { - (answer as { setMessageId?: (value: number | undefined) => void }).setMessageId?.( - params.answerMessageIdAfterStop, - ); - } await lane.stream?.stop(); }); - const editPreview = vi.fn().mockResolvedValue(undefined); - const deletePreviewMessage = vi.fn().mockResolvedValue(undefined); + const clearDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => { + await lane.stream?.clear(); + }); + const editStreamMessage = vi.fn().mockResolvedValue(undefined); const log = vi.fn(); const markDelivered = vi.fn(); - const activePreviewLifecycleByLane = { answer: "transient", reasoning: "transient" } as const; - const retainPreviewOnCleanupByLane = { answer: false, reasoning: false } as const; - const archivedAnswerPreviews: ArchivedPreview[] = []; const deliverLaneText = createLaneTextDeliverer({ lanes, - archivedAnswerPreviews, - activePreviewLifecycleByLane: { ...activePreviewLifecycleByLane }, - retainPreviewOnCleanupByLane: { ...retainPreviewOnCleanupByLane }, draftMaxChars: params?.draftMaxChars ?? 4_096, applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }), - splitFinalTextForPreview: params?.splitFinalTextForPreview, + splitFinalTextForStream: params?.splitFinalTextForStream, sendPayload, flushDraftLane, stopDraftLane, - editPreview, - deletePreviewMessage, + clearDraftLane, + editStreamMessage, log, markDelivered, - now: params?.nowMs != null ? () => params.nowMs! : undefined, }); return { deliverLaneText, lanes, - answer: { - stream: answer, - setMessageId: (answer as { setMessageId?: (value: number | undefined) => void }).setMessageId, - }, + answer, + reasoning, sendPayload, flushDraftLane, stopDraftLane, - editPreview, - deletePreviewMessage, + clearDraftLane, + editStreamMessage, log, markDelivered, - archivedAnswerPreviews, }; } @@ -109,589 +87,183 @@ async function deliverFinalAnswer(harness: ReturnType, tex }); } -async function expectFinalPreviewRetained(params: { - harness: ReturnType; - text?: string; - expectedLogSnippet?: string; -}) { - const result = await deliverFinalAnswer(params.harness, params.text ?? HELLO_FINAL); - expect(result.kind).toBe("preview-retained"); - expect(params.harness.sendPayload).not.toHaveBeenCalled(); - if (params.expectedLogSnippet) { - expect(params.harness.log).toHaveBeenCalledWith( - expect.stringContaining(params.expectedLogSnippet), - ); - } -} - -function seedArchivedAnswerPreview(harness: ReturnType) { - harness.archivedAnswerPreviews.push({ - messageId: 5555, - textSnapshot: "Partial streaming...", - deleteIfUnused: true, - }); -} - -async function expectFinalEditFallbackToSend(params: { - harness: ReturnType; - text: string; - expectedLogSnippet: string; -}) { - const result = await deliverFinalAnswer(params.harness, params.text); - expect(result.kind).toBe("sent"); - expect(params.harness.editPreview).toHaveBeenCalledTimes(1); - expectSendPayloadWith(params.harness, { text: params.text }); - expect(params.harness.log).toHaveBeenCalledWith( - expect.stringContaining(params.expectedLogSnippet), - ); -} - -function expectSendPayloadWith( - harness: ReturnType, - expected: Partial, -) { - expect( - harness.sendPayload.mock.calls.some(([payload]) => - Object.entries(expected).every(([key, value]) => { - return (payload as Record)[key] === value; - }), - ), - ).toBe(true); -} - -function expectPreviewFinalized(result: LaneDeliveryResult): { - content: string; - messageId: number; -} { +function expectPreviewFinalized( + result: LaneDeliveryResult, +): Extract["delivery"] { expect(result.kind).toBe("preview-finalized"); if (result.kind !== "preview-finalized") { throw new Error(`expected preview-finalized, got ${result.kind}`); } - expect(result.delivery.receipt).toEqual( - expect.objectContaining({ - primaryPlatformMessageId: String(result.delivery.messageId), - platformMessageIds: [String(result.delivery.messageId)], - }), - ); - return { - content: result.delivery.content, - messageId: result.delivery.messageId, - }; + return result.delivery; } describe("createLaneTextDeliverer", () => { - it("finalizes text-only replies by editing an existing preview message", async () => { + it("finalizes text-only replies in the active stream message", async () => { const harness = createHarness({ answerMessageId: 999 }); const result = await deliverFinalAnswer(harness, HELLO_FINAL); - expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 }); - expect(harness.editPreview).toHaveBeenCalledWith( - expect.objectContaining({ - laneName: "answer", - messageId: 999, - text: HELLO_FINAL, - context: "final", - }), - ); - expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(expectPreviewFinalized(result)).toMatchObject({ + content: HELLO_FINAL, + messageId: 999, + receipt: { primaryPlatformMessageId: "999" }, + }); + expect(harness.answer?.update).toHaveBeenCalledWith(HELLO_FINAL); expect(harness.stopDraftLane).toHaveBeenCalledTimes(1); - }); - - it("primes stop-created previews with final text before editing", async () => { - const harness = createHarness({ - answerMessageIdAfterStop: 777, - answerHasStreamedMessage: true, - }); - harness.lanes.answer.lastPartialText = "no"; - - const result = await harness.deliverLaneText({ - laneName: "answer", - text: "no problem", - payload: { text: "no problem" }, - infoKind: "final", - }); - - expect(expectPreviewFinalized(result)).toEqual({ content: "no problem", messageId: 777 }); - 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("keeps stop-created preview when follow-up final edit fails", async () => { - const harness = createHarness({ - answerMessageIdAfterStop: 777, - answerHasStreamedMessage: true, - }); - 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.kind).toBe("preview-retained"); - expect(harness.editPreview).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(harness.log).toHaveBeenCalledWith( - expect.stringContaining("failed after stop flush; keeping existing preview"), - ); - }); - - it("treats 'message is not modified' preview edit errors as delivered", async () => { - const harness = createHarness({ answerMessageId: 999 }); - harness.editPreview.mockRejectedValue( - new Error( - "400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message", - ), - ); - - const result = await deliverFinalAnswer(harness, HELLO_FINAL); - - expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 }); - expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); expect(harness.markDelivered).toHaveBeenCalledTimes(1); - expect(harness.log).toHaveBeenCalledWith( - expect.stringContaining('edit returned "message is not modified"; treating as delivered'), - ); + expect(harness.lanes.answer.finalized).toBe(true); }); - it("retains preview when an existing preview final edit fails with ambiguous error", async () => { + it("streams block and final text through the same lane", async () => { const harness = createHarness({ answerMessageId: 999 }); - // Plain Error with no error_code → ambiguous. Retain unless the preview is - // known to be an incomplete prefix of the final text. - harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); - await expectFinalPreviewRetained({ - harness, - expectedLogSnippet: "ambiguous error; keeping existing preview to avoid duplicate", - }); - expect(harness.editPreview).toHaveBeenCalledTimes(1); - }); - - it("falls back when an ambiguous final edit failure would leave an incomplete preview", async () => { - const harness = createHarness({ - answerMessageId: 999, - answerLastPartialText: "Hello fi", - }); - harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); - - await expectFinalEditFallbackToSend({ - harness, - text: HELLO_FINAL, - expectedLogSnippet: "preview is an incomplete prefix; falling back", - }); - }); - - it("falls back when Telegram reports the current final edit target missing", async () => { - const harness = createHarness({ answerMessageId: 999 }); - harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); - - await expectFinalEditFallbackToSend({ - harness, - text: "Hello final", - expectedLogSnippet: "edit target missing with no alternate preview; falling back", - }); - }); - - 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 deliverFinalAnswer(harness, HELLO_FINAL); - - expect(result.kind).toBe("sent"); - expectSendPayloadWith(harness, { 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")); - - await expectFinalPreviewRetained({ - harness, - expectedLogSnippet: "may have landed despite network error; keeping existing preview", - }); - }); - - it("falls back to normal delivery when stop-created preview has no message id", async () => { - const harness = createHarness(); - - const result = await harness.deliverLaneText({ + const blockResult = await harness.deliverLaneText({ laneName: "answer", - text: "Short final", - payload: { text: "Short final" }, - infoKind: "final", + text: "working", + payload: { text: "working" }, + infoKind: "block", }); + const finalResult = await deliverFinalAnswer(harness, "done"); - expect(result.kind).toBe("sent"); - expect(harness.editPreview).not.toHaveBeenCalled(); - expectSendPayloadWith(harness, { text: "Short final" }); - }); - - it("does not create a synthetic preview for final-only text", async () => { - const answerStream = createSequencedTestDraftStream(777); - const harness = createHarness({ - answerStream: answerStream as DraftLaneState["stream"], - answerHasStreamedMessage: false, - }); - - const result = await harness.deliverLaneText({ - laneName: "answer", - text: "Final only", - payload: { text: "Final only" }, - infoKind: "final", - }); - - expect(result.kind).toBe("sent"); - expect(answerStream.update).not.toHaveBeenCalled(); - expect(answerStream.materialize).not.toHaveBeenCalled(); - expect(harness.editPreview).not.toHaveBeenCalled(); - expectSendPayloadWith(harness, { text: "Final only" }); - }); - - 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(expectPreviewFinalized(result)).toEqual({ - content: "Recovered final answer.", + expect(blockResult.kind).toBe("preview-updated"); + expect(expectPreviewFinalized(finalResult)).toMatchObject({ + content: "done", messageId: 999, }); - 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.kind).toBe("sent"); - expect(harness.editPreview).not.toHaveBeenCalled(); - expectSendPayloadWith(harness, { text: longText }); - expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long")); - }); - - it("forces a long final preview back to the first chunk before sending the rest", async () => { - const firstChunk = "First chunk boundary."; - const remainingText = " Follow-up body after the boundary."; - const finalText = `${firstChunk}${remainingText}`; - const harness = createHarness({ - answerMessageId: 999, - answerHasStreamedMessage: true, - answerLastPartialText: `${firstChunk} overlap already visible`, - draftMaxChars: 24, - splitFinalTextForPreview: () => [firstChunk, remainingText], - }); - - const result = await deliverFinalAnswer(harness, finalText); - - expect(expectPreviewFinalized(result)).toEqual({ - content: finalText, - messageId: 999, - }); - expect(harness.editPreview).toHaveBeenCalledWith( - expect.objectContaining({ - messageId: 999, - text: firstChunk, - }), - ); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: remainingText }), - ); - expect(harness.lanes.answer.lastPartialText).toBe(firstChunk); - }); - - it("sends a fresh final when a message preview is long lived", async () => { - const visibleSinceMs = 10_000; - const harness = createHarness({ - answerMessageId: 999, - answerHasStreamedMessage: true, - answerLastPartialText: "Working...", - answerPreviewVisibleSinceMs: visibleSinceMs, - nowMs: visibleSinceMs + 60_000, - }); - - const result = await deliverFinalAnswer(harness, HELLO_FINAL); - - expect(result.kind).toBe("sent"); + expect(harness.answer?.update).toHaveBeenNthCalledWith(1, "working"); + expect(harness.answer?.update).toHaveBeenNthCalledWith(2, "done"); + expect(harness.flushDraftLane).toHaveBeenCalledTimes(1); expect(harness.stopDraftLane).toHaveBeenCalledTimes(1); - expectSendPayloadWith(harness, { text: HELLO_FINAL }); - expect(harness.editPreview).not.toHaveBeenCalled(); - expect(harness.answer.stream?.clear).toHaveBeenCalledTimes(1); - expect(harness.answer.stream?.forceNewMessage).toHaveBeenCalledTimes(1); - expect(harness.lanes.answer.hasStreamedMessage).toBe(false); - expect(harness.lanes.answer.lastPartialText).toBe(""); + expect(harness.sendPayload).not.toHaveBeenCalled(); + }); + + it("uses normal final delivery when the stream edit leaves stale text", async () => { + const answer = createTestDraftStream({ messageId: 999 }); + answer.lastDeliveredText.mockReturnValue("working"); + const harness = createHarness({ answerStream: answer }); + + const result = await deliverFinalAnswer(harness, "done"); + + expect(result.kind).toBe("sent"); + expect(answer.update).toHaveBeenCalledWith("done"); + expect(harness.clearDraftLane).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith({ text: "done" }, { durable: true }); expect(harness.markDelivered).not.toHaveBeenCalled(); + expect(harness.lanes.answer.finalized).toBe(true); }); - it("falls back to editing a long-lived preview when fresh final send returns false", async () => { - const visibleSinceMs = 10_000; - const harness = createHarness({ - answerMessageId: 999, - answerHasStreamedMessage: true, - answerLastPartialText: "Working...", - answerPreviewVisibleSinceMs: visibleSinceMs, - nowMs: visibleSinceMs + 60_000, - }); - harness.sendPayload.mockResolvedValueOnce(false); - - const result = await deliverFinalAnswer(harness, HELLO_FINAL); - - expect(expectPreviewFinalized(result)).toEqual({ - content: HELLO_FINAL, - messageId: 999, - }); - expect(harness.stopDraftLane).toHaveBeenCalledTimes(2); - expect(harness.sendPayload).toHaveBeenCalledTimes(1); - expect(harness.editPreview).toHaveBeenCalledWith( - expect.objectContaining({ - messageId: 999, - text: HELLO_FINAL, - }), - ); - expect(harness.answer.stream?.clear).not.toHaveBeenCalled(); - expect(harness.markDelivered).toHaveBeenCalledTimes(1); - }); - - it("sends a fresh final for stale archived previews", async () => { - const visibleSinceMs = 10_000; - const harness = createHarness({ - answerMessageId: 1001, - answerPreviewVisibleSinceMs: visibleSinceMs, - nowMs: visibleSinceMs + 60_000, - }); - harness.archivedAnswerPreviews.push({ - messageId: 222, - textSnapshot: "Working...", - visibleSinceMs, - deleteIfUnused: true, - }); + it("falls back to normal delivery when no stream exists", async () => { + const harness = createHarness({ answerStream: null }); const result = await deliverFinalAnswer(harness, HELLO_FINAL); expect(result.kind).toBe("sent"); - expectSendPayloadWith(harness, { text: HELLO_FINAL }); - expect(harness.editPreview).not.toHaveBeenCalled(); - expect(harness.deletePreviewMessage).toHaveBeenCalledWith(222); + expect(harness.sendPayload).toHaveBeenCalledWith({ text: HELLO_FINAL }, { durable: true }); + expect(harness.clearDraftLane).not.toHaveBeenCalled(); + expect(harness.lanes.answer.finalized).toBe(true); }); - it("falls back to editing a stale archived preview when fresh final send returns false", async () => { - const visibleSinceMs = 10_000; - const harness = createHarness({ - answerMessageId: 1001, - answerPreviewVisibleSinceMs: visibleSinceMs, - nowMs: visibleSinceMs + 60_000, - }); - harness.archivedAnswerPreviews.push({ - messageId: 222, - textSnapshot: "Working...", - visibleSinceMs, - deleteIfUnused: true, - }); - harness.sendPayload.mockResolvedValueOnce(false); - - const result = await deliverFinalAnswer(harness, HELLO_FINAL); - - expect(expectPreviewFinalized(result)).toEqual({ - content: HELLO_FINAL, - messageId: 222, - }); - expect(harness.sendPayload).toHaveBeenCalledTimes(1); - expect(harness.editPreview).toHaveBeenCalledWith( - expect.objectContaining({ - messageId: 222, - text: HELLO_FINAL, - }), - ); - expect(harness.deletePreviewMessage).not.toHaveBeenCalled(); - expect(harness.markDelivered).toHaveBeenCalledTimes(1); - }); - - // ── Duplicate message regression tests ────────────────────────────────── - // During final delivery, only ambiguous post-connect failures keep the - // preview. Definite non-delivery falls back to a real send. - - it("retains preview on ambiguous API error during final", async () => { + it("clears unfinalized stream state before non-stream final delivery", async () => { const harness = createHarness({ answerMessageId: 999 }); - // Plain Error with no error_code → ambiguous, prefer incomplete over duplicate - harness.editPreview.mockRejectedValue(new Error("500: Internal Server Error")); - - await expectFinalPreviewRetained({ harness }); - expect(harness.editPreview).toHaveBeenCalledTimes(1); - }); - - it("falls back when an archived preview edit target is missing and no alternate preview exists", async () => { - const harness = createHarness(); - seedArchivedAnswerPreview(harness); - harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); - - const result = await deliverFinalAnswer(harness, "Complete final answer"); - - expect(harness.editPreview).toHaveBeenCalledTimes(1); - expectSendPayloadWith(harness, { text: "Complete final answer" }); - expect(result.kind).toBe("sent"); - expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); - }); - - it("falls back when an archived preview ambiguous final edit would leave an incomplete prefix", async () => { - const harness = createHarness(); - harness.archivedAnswerPreviews.push({ - messageId: 5555, - textSnapshot: "Hello fi", - deleteIfUnused: true, - }); - harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); - - await expectFinalEditFallbackToSend({ - harness, - text: HELLO_FINAL, - expectedLogSnippet: "preview is an incomplete prefix; falling back", - }); - expect(harness.editPreview).toHaveBeenCalledWith( - expect.objectContaining({ - messageId: 5555, - text: HELLO_FINAL, - }), - ); - expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); - }); - - it("keeps the active preview when an archived final edit target is missing", async () => { - const harness = createHarness({ answerMessageId: 999 }); - seedArchivedAnswerPreview(harness); - harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); - - const result = await deliverFinalAnswer(harness, "Complete final answer"); - - expect(harness.editPreview).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(result.kind).toBe("preview-retained"); - expect(harness.log).toHaveBeenCalledWith( - expect.stringContaining("edit target missing; keeping alternate preview without fallback"), - ); - }); - - it("keeps the archived preview when the final text regresses", async () => { - const harness = createHarness(); - harness.archivedAnswerPreviews.push({ - messageId: 5555, - textSnapshot: "Recovered final answer.", - deleteIfUnused: true, - }); - - const result = await deliverFinalAnswer(harness, "Recovered final answer"); - - expect(expectPreviewFinalized(result)).toEqual({ - content: "Recovered final answer.", - messageId: 5555, - }); - expect(harness.editPreview).not.toHaveBeenCalled(); - expect(harness.sendPayload).not.toHaveBeenCalled(); - }); - - it("falls back on 4xx client rejection with error_code during final", async () => { - const harness = createHarness({ answerMessageId: 999 }); - const err = Object.assign(new Error("403: Forbidden"), { error_code: 403 }); - harness.editPreview.mockRejectedValue(err); - - await expectFinalEditFallbackToSend({ - harness, - text: "Hello final", - expectedLogSnippet: "rejected by Telegram (client error); falling back", - }); - }); - - it("retains preview on 502 with error_code during final (ambiguous server error)", async () => { - const harness = createHarness({ answerMessageId: 999 }); - const err = Object.assign(new Error("502: Bad Gateway"), { error_code: 502 }); - harness.editPreview.mockRejectedValue(err); - - await expectFinalPreviewRetained({ - harness, - expectedLogSnippet: "ambiguous error; keeping existing preview to avoid duplicate", - }); - }); - - it("falls back when the first preview send may have landed without a message id", async () => { - const stream = createTestDraftStream(); - stream.sendMayHaveLanded.mockReturnValue(true); - const harness = createHarness({ answerStream: stream }); - - const result = await deliverFinalAnswer(harness, HELLO_FINAL); - - expect(result.kind).toBe("sent"); - expectSendPayloadWith(harness, { text: HELLO_FINAL }); - }); - - it("retains when sendMayHaveLanded is true and a prior preview was visible", async () => { - // Stream has a messageId (visible preview) but loses it after stop - const stream = createTestDraftStream({ messageId: 999 }); - stream.sendMayHaveLanded.mockReturnValue(true); - const harness = createHarness({ - answerStream: stream, - answerHasStreamedMessage: true, - }); - // Simulate messageId lost after stop (e.g. forceNewMessage or timeout) - harness.stopDraftLane.mockImplementation(async (lane: DraftLaneState) => { - stream.setMessageId(undefined); - await lane.stream?.stop(); - }); - - await expectFinalPreviewRetained({ - harness, - expectedLogSnippet: "preview send may have landed despite missing message id", - }); - }); - - it("deletes consumed boundary previews after fallback final send", async () => { - const harness = createHarness(); - harness.archivedAnswerPreviews.push({ - messageId: 4444, - textSnapshot: "Boundary preview", - deleteIfUnused: false, - }); const result = await harness.deliverLaneText({ laneName: "answer", - text: "Final with media", - payload: { text: "Final with media", mediaUrl: "file:///tmp/example.png" }, + text: "photo", + payload: { text: "photo", mediaUrl: "https://example.com/a.png" }, infoKind: "final", }); expect(result.kind).toBe("sent"); - expectSendPayloadWith(harness, { - text: "Final with media", - mediaUrl: "file:///tmp/example.png", + expect(harness.clearDraftLane).toHaveBeenCalledTimes(1); + expect(harness.answer?.clear).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith( + { + text: "photo", + mediaUrl: "https://example.com/a.png", + }, + { durable: true }, + ); + }); + + it("streams the first long final chunk and sends follow-up chunks", async () => { + const harness = createHarness({ + answerMessageId: 999, + draftMaxChars: 5, + splitFinalTextForStream: () => ["Hello", " world", " again"], }); - expect(harness.deletePreviewMessage).toHaveBeenCalledWith(4444); + + const result = await deliverFinalAnswer(harness, "Hello world again"); + + expect(expectPreviewFinalized(result)).toMatchObject({ + content: "Hello world again", + messageId: 999, + }); + expect(harness.answer?.update).toHaveBeenCalledWith("Hello"); + expect(harness.sendPayload).toHaveBeenCalledTimes(2); + expect(harness.sendPayload).toHaveBeenNthCalledWith(1, { text: " world" }); + expect(harness.sendPayload).toHaveBeenNthCalledWith(2, { text: " again" }); + }); + + it("retains the streamed message when stop may have landed without a message id", async () => { + const answer = createTestDraftStream(); + answer.sendMayHaveLanded.mockReturnValue(true); + const harness = createHarness({ answerStream: answer }); + + const result = await deliverFinalAnswer(harness, HELLO_FINAL); + + expect(result.kind).toBe("preview-retained"); + expect(answer.update).toHaveBeenCalledWith(HELLO_FINAL); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + expect(harness.lanes.answer.finalized).toBe(true); + }); + + it("attaches buttons to the stream message without sending a second reply", async () => { + const harness = createHarness({ answerMessageId: 999 }); + const buttons = [[{ text: "OK", callback_data: "ok" }]]; + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: HELLO_FINAL, + payload: { text: HELLO_FINAL, channelData: { telegram: { buttons } } }, + infoKind: "final", + buttons, + }); + + expect(expectPreviewFinalized(result)).toMatchObject({ + content: HELLO_FINAL, + messageId: 999, + }); + expect(harness.editStreamMessage).toHaveBeenCalledWith({ + laneName: "answer", + messageId: 999, + text: HELLO_FINAL, + buttons, + }); + expect(harness.sendPayload).not.toHaveBeenCalled(); + }); + + it("keeps the stream delivery when button attachment fails", async () => { + const harness = createHarness({ answerMessageId: 999 }); + const buttons = [[{ text: "OK", callback_data: "ok" }]]; + harness.editStreamMessage.mockRejectedValue(new Error("400: button rejected")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: HELLO_FINAL, + payload: { text: HELLO_FINAL, channelData: { telegram: { buttons } } }, + infoKind: "final", + buttons, + }); + + expect(expectPreviewFinalized(result)).toMatchObject({ + content: HELLO_FINAL, + messageId: 999, + }); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith( + "telegram: answer stream button edit failed: Error: 400: button rejected", + ); }); });