import { expect, it } from "vitest"; import { describeTelegramDispatch, createContext, createDirectSessionPayload, createReasoningStreamContext, deliverReplies, dispatchReplyWithBufferedBlockDispatcher, dispatchWithContext, editMessageTelegram, expectDeliveredReply, expectDeliverRepliesParams, expectWindowCollapsedTo, requireInvocationOrder, setupDraftStreams, telegramProgressPreview, } from "./bot-message-dispatch.test-harness.js"; describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () => { it("sends an error fallback when dispatch fails after only partial output", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "partial answer" }, { kind: "block" }); throw new Error("dispatch failed after partial output"); }); await dispatchWithContext({ context: createContext({ ctxPayload: createDirectSessionPayload(), }), streamMode: "off", }); expect(deliverReplies).toHaveBeenCalledTimes(2); expectDeliveredReply(0, { text: "partial answer" }); expectDeliveredReply( 0, { text: "Something went wrong while processing your request. Please try again.", }, 1, ); }); it("returns retryable when dispatch fails after partial output and the fallback is not delivered", async () => { deliverReplies.mockResolvedValueOnce({ delivered: true }); deliverReplies.mockResolvedValueOnce({ delivered: false }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "partial answer" }, { kind: "block" }); throw new Error("dispatch failed after partial output"); }); const result = await dispatchWithContext({ context: createContext({ ctxPayload: createDirectSessionPayload(), }), retryDispatchErrors: true, streamMode: "off", }); expect(result).toMatchObject({ kind: "failed-retryable" }); expect((result as { error?: unknown }).error).toBeInstanceOf(Error); expect(deliverReplies).toHaveBeenCalledTimes(2); expectDeliveredReply(0, { text: "partial answer" }); expectDeliveredReply( 0, { text: "Something went wrong while processing your request. Please try again.", }, 1, ); }); it("returns retryable when spooled replay suppresses fallback after non-silent delivery skip", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { dispatcherOptions.onSkip?.({ text: "final answer" }, { kind: "final", reason: "empty" }); return { queuedFinal: false }; }); const result = await dispatchWithContext({ context: createContext(), retryDispatchErrors: true, suppressFailureFallback: true, }); expect(result).toMatchObject({ kind: "failed-retryable" }); expect((result as { error?: unknown }).error).toBeInstanceOf(Error); expect(deliverReplies).not.toHaveBeenCalled(); }); it("does not return retryable after spooled replay already showed visible output", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "partial answer" }, { kind: "block" }); dispatcherOptions.onSkip?.({ text: "final answer" }, { kind: "final", reason: "empty" }); return { queuedFinal: false }; }); const result = await dispatchWithContext({ context: createContext(), retryDispatchErrors: true, suppressFailureFallback: true, }); expect(result).toEqual({ kind: "completed" }); expect(answerDraftStream.update).toHaveBeenCalledWith("partial answer"); expect(deliverReplies).not.toHaveBeenCalled(); }); it("keeps tool progress visible after a partial-streamed intermediate block", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onPartialReply?.({ text: "Site A shows X." }); await dispatcherOptions.deliver({ text: "Site A shows X." }, { kind: "block" }); await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext() }); expect(answerDraftStream.update.mock.calls).toEqual([["Site A shows X."], ["Final answer"]]); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec<\/b>$/) }), ); // The tool-progress window repositions before the final (deferred delete), // never an immediate clear/delete. expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1); // The reposition rewinds the stream BEFORE any deliverer cleanup clear(), // so that clear finds no live message id and never deletes the window. if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) { expect( requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ), ).toBeLessThan( requireInvocationOrder(answerDraftStream.clear, 0, "first answer draft clear"), ); } const progressResetOrder = requireInvocationOrder( answerDraftStream.forceNewMessage, 0, "first answer draft rotation", ); const progressUpdateOrder = requireInvocationOrder( answerDraftStream.updatePreview, 0, "first answer preview update", ); expect(progressResetOrder).toBeLessThan(progressUpdateOrder); expect(deliverReplies).not.toHaveBeenCalled(); }); it("preserves streamed text blocks that follow tool progress before the final answer", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await dispatcherOptions.deliver({ text: "Site A shows X." }, { kind: "block" }); await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver({ text: "Site B shows Y." }, { kind: "block" }); await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext() }); expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X."); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec<\/b>$/) }), ); expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site B shows Y."); expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer"); // The tool-progress window repositions (deferred delete) rather than an // immediate clear when the following text block takes over the lane. expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1); // The reposition rewinds the stream BEFORE any deliverer cleanup clear(), // so that clear finds no live message id and never deletes the window. if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) { expect( requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ), ).toBeLessThan( requireInvocationOrder(answerDraftStream.clear, 0, "first answer draft clear"), ); } 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("rotates a tool-progress-only answer draft before streaming the final answer", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext() }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec<\/b>$/) }), ); expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date"); // Reposition, not delete-then-repost: the tool-progress window is rewound // for a new message and its delete deferred until after the replacement // lands. clear() (immediate delete) must NOT run — that scroll-jumps. expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1); // The reposition rewinds the stream BEFORE any deliverer cleanup clear(), // so that clear finds no live message id and never deletes the window. if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) { expect( requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ), ).toBeLessThan( requireInvocationOrder(answerDraftStream.clear, 0, "first answer draft clear"), ); } const rotationOrder = requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ); const finalUpdateOrder = requireInvocationOrder( answerDraftStream.update, 0, "first answer draft update", ); expect(rotationOrder).toBeLessThan(finalUpdateOrder); }); it("clears a tool-progress-only draft across assistant boundaries before final text", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await replyOptions?.onAssistantMessageStart?.(); await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext() }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec<\/b>$/) }), ); expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date"); // Across an assistant boundary the tool-progress window still repositions // (new message first, deferred delete) rather than deleting immediately. expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1); // The reposition rewinds the stream BEFORE any deliverer cleanup clear(), // so that clear finds no live message id and never deletes the window. if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) { expect( requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ), ).toBeLessThan( requireInvocationOrder(answerDraftStream.clear, 0, "first answer draft clear"), ); } const rotationOrder = requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ); const finalUpdateOrder = requireInvocationOrder( answerDraftStream.update, 0, "first answer draft update", ); expect(rotationOrder).toBeLessThan(finalUpdateOrder); }); it("rotates a verbose tool result draft before streaming the final answer", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "🛠️ Exec: pnpm test" }, { kind: "tool" }); await dispatcherOptions.deliver({ text: "Tests passed" }, { kind: "final" }); return { queuedFinal: true }; }); await dispatchWithContext({ context: createContext() }); expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "🛠️ Exec: pnpm test"); expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Tests passed"); // Verbose tool result window repositions before the final: new message // first, superseded delete deferred (no immediate clear/delete). expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1); // The reposition rewinds the stream BEFORE any deliverer cleanup clear(), // so that clear finds no live message id and never deletes the window. if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) { expect( requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ), ).toBeLessThan( requireInvocationOrder(answerDraftStream.clear, 0, "first answer draft clear"), ); } const rotationOrder = requireInvocationOrder( answerDraftStream.rotateToNewMessageDeferringDelete, 0, "first deferred answer draft rotation", ); const finalUpdateOrder = requireInvocationOrder( answerDraftStream.update, 1, "second answer draft update", ); expect(rotationOrder).toBeLessThan(finalUpdateOrder); }); it("keeps progress updates in a draft and sends the final answer normally", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); answerDraftStream.hasConsumedReplyTarget.mockReturnValue(true); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await replyOptions?.onItemEvent?.({ kind: "command", name: "exec", progressText: "git rev-parse --abbrev-ref HEAD", }); await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( telegramProgressPreview( "Cracking\n\n🛠️ Exec\n🛠️ git rev-parse --abbrev-ref HEAD", "Cracking\n🛠️ Exec\n🛠️ Exec git rev-parse --abbrev-ref HEAD", ), ); expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date"); expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); // The window collapses IN PLACE into the one-line activity summary (edit, // not delete + repost — Discord parity), so clear() is never called on it. expect(answerDraftStream.clear).not.toHaveBeenCalled(); expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s"); expectDeliveredReply(0, { text: "Branch is up to date" }); expectDeliverRepliesParams({ replyToMode: "off" }); // The final answer is SENT before the window collapses into the bar: sending // first keeps the final at the bottom of the anchored viewport, so shrinking // the tall window above it never drops the final off screen. expect(requireInvocationOrder(deliverReplies, 0, "first reply delivery")).toBeLessThan( requireInvocationOrder( answerDraftStream.finalizeToPreview, 0, "first answer draft finalization", ), ); expect(editMessageTelegram).not.toHaveBeenCalled(); }); it("delivers a block-only progress turn as the terminal answer", async () => { const { answerDraftStream } = setupDraftStreams(); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver( { text: "Terminal block answer" }, { kind: "block", assistantMessageIndex: 0 }, ); return { queuedFinal: false, counts: { block: 1, final: 0, tool: 0 } }; }); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.update).not.toHaveBeenCalledWith("Terminal block answer"); expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled(); expectDeliveredReply(0, { text: "Terminal block answer" }); }); it("uses a block-only terminal answer instead of prior tool-progress text", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver( { text: "Terminal block after tool" }, { kind: "block", assistantMessageIndex: 0 }, ); return { queuedFinal: false, counts: { block: 1, final: 0, tool: 1 } }; }, ); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringContaining("Exec") }), ); expectDeliveredReply(0, { text: "Terminal block after tool" }); expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s"); expect(requireInvocationOrder(deliverReplies, 0, "first reply delivery")).toBeLessThan( requireInvocationOrder( answerDraftStream.finalizeToPreview, 0, "first answer draft finalization", ), ); }); function allDeliveredReplyTexts(): string[] { return deliverReplies.mock.calls.flatMap((call: unknown[]) => ((call[0] as { replies?: Array<{ text?: string }> }).replies ?? []).map( (reply) => reply.text ?? "", ), ); } it("sends the final answer before collapsing the window into the bar", async () => { // Edit-shrink anchor loss: shrinking the tall window to a one-line bar BEFORE // the final is sent breaks the client's at-bottom follow and drops the final // off screen. The final must be sent FIRST, then the window edited down. const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver({ text: "All done" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress" } }, }); // Final delivered, then the window edited into the bar — final send precedes // the collapse edit. expectDeliveredReply(0, { text: "All done" }); expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s"); expect(requireInvocationOrder(deliverReplies, 0, "first reply delivery")).toBeLessThan( requireInvocationOrder( answerDraftStream.finalizeToPreview, 0, "first answer draft finalization", ), ); // The bar counters are snapshotted before the final send, so the count is // stable (one tool call — the final's own delivery does not perturb it). expect(answerDraftStream.finalizeToPreview).toHaveBeenCalledTimes(1); expect(answerDraftStream.clear).not.toHaveBeenCalled(); }); it("still collapses the window when the final answer send is skipped", async () => { // Failure path: if the final send skips/fails, the window must not be left // stale — it still collapses to the bar (once-guard already consumed). const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); deliverReplies.mockResolvedValue({ delivered: false }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver({ text: "Answer that fails to send" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress" } }, }); // The bar still edits the window in place even though the final send failed. expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s"); }); it("tallies reasoning bursts and tool calls into the collapse summary", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { // burst 1 → tool → burst 2 → tool, then a trailing burst flushed at the // summary: 3 thoughts, 2 tool calls. await replyOptions?.onReasoningStream?.({ text: "thinking a" }); await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await replyOptions?.onReasoningStream?.({ text: "thinking b" }); await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await replyOptions?.onReasoningStream?.({ text: "thinking c" }); await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); return { queuedFinal: true }; }, ); await dispatchWithContext({ // Reasoning must resolve to "stream" so thoughts route into the progress // window — only window-streamed reasoning feeds the collapse summary. context: createReasoningStreamContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress" } }, }); expectWindowCollapsedTo(answerDraftStream, "🧠 3 thoughts · 🛠️ 2 tool calls · ⏱️ 1s"); expectDeliveredReply(0, { text: "Done" }); }); it("does not post a collapse summary when no progress draft started", async () => { setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { // No tools, thoughts, or notes — nothing collapses; just a final answer. await dispatcherOptions.deliver({ text: "Just an answer" }, { kind: "final" }); return { queuedFinal: true }; }); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress" } }, }); const texts = allDeliveredReplyTexts(); expect(texts.some((text) => text.includes("⏱️"))).toBe(false); expect(texts).toContain("Just an answer"); }); it("does not post a collapse summary before an error final", async () => { setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); await dispatcherOptions.deliver( { text: "Something went wrong", isError: true }, { kind: "final" }, ); return { queuedFinal: true }; }, ); await dispatchWithContext({ context: createContext(), streamMode: "progress", telegramCfg: { streaming: { mode: "progress" } }, }); const texts = allDeliveredReplyTexts(); expect(texts.some((text) => text.includes("tool call · ⏱️"))).toBe(false); }); });