diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 27b7f5366141..f9c445be913f 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -2822,6 +2822,76 @@ describe("dispatchTelegramMessage draft streaming", () => { expectDeliveredReply(0, { text: "Done" }); }); + it("collapses a tool-progress-only window without deleting when reasoning is durable and the lane rotated mid-turn (on-off)", async () => { + // on-off cell: /reasoning on (durable), /verbose off. The window streams + // tool progress only; a mid-turn assistant boundary/rotation must not leave + // the collapse to a delete + repost. Every non-error collapse edits in place + // (or posts the bar durably) — NEVER a bare clear()/deleteMessage — so there + // is exactly one bar and no Telegram focus-jump. + loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } }); + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + // Durable reasoning + an assistant boundary land between tool progress + // and the final — the mid-turn churn that dropped the live window id. + await dispatcherOptions.deliver( + { text: "hidden", isReasoning: true }, + { kind: "block" }, + ); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], + }), + streamMode: "progress", + telegramCfg: { streaming: { mode: "progress" } }, + }); + + // Collapse edited the window in place into the bar; the window was NOT + // deleted (no focus-jump), and exactly one bar exists. + expectWindowCollapsedTo(answerDraftStream, "🛠️ 2 tool calls · ⏱️ 1s"); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + const texts = allDeliveredReplyTexts(); + expect(texts.filter((text) => text.includes("⏱️"))).toHaveLength(0); // bar is the in-place edit + expect(texts).toContain("Done"); + }); + + it("posts the collapse bar durably with no delete when the window has no live message", async () => { + // When finalizeToPreview cannot edit in place (no live window message id), + // the bar is still surfaced — as a durable post — and the window is NOT + // cleared/deleted (nothing to delete; never a bare clear when a bar exists). + const answerDraftStream = createTestDraftStream({}); // no messageId -> edit fails + const reasoningDraftStream = createTestDraftStream({}); + createTelegramDraftStream + .mockImplementationOnce(() => answerDraftStream) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ + context: createContext(), + streamMode: "progress", + telegramCfg: { streaming: { mode: "progress" } }, + }); + + const texts = allDeliveredReplyTexts(); + expect(texts.filter((text) => text.includes("⏱️"))).toEqual(["🛠️ 1 tool call · ⏱️ 1s"]); + expect(texts).toContain("Done"); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + }); + it("does not duplicate tool lines into the window under verbose", async () => { // Invariant D2 (persistent XOR window): when the durable verbose lane owns // tool messages, the window must render no tool line and must not count it. diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 66db0c5d3266..a220e58cbc77 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -1946,54 +1946,73 @@ export const dispatchTelegramMessage = async ({ } await sendPayload({ text: line }, { durable: true }); }; - // Collapse the live window IN PLACE into the summary bar: edit the existing - // window message so its content becomes the bar line, keeping it on screen. - // Mirrors Discord — deleting the window and reposting the bar scroll-jumps - // the Telegram client and flashes the window away. Returns true when the - // window was collapsed in place; false when there is no bar (nothing - // streamed) or no live window message, so the caller tears the window down. - const collapseProgressWindowIntoSummary = async (): Promise => { + // Collapse the progress window into the summary bar. ONE deterministic path + // for every mode (off-off, stream-off, on-off tool-progress-only): edit the + // live window message IN PLACE into the bar (no delete — deleting scroll- + // jumps the Telegram client). Only when there is genuinely no live window + // message (rv mode never rendered a message) is the bar posted durably, and + // even then NOTHING is deleted. Returns "edited" | "posted" | "none" so the + // caller resets lane state without ever falling back to a bare clear() when + // a bar exists. finalizeToPreview settles pending previews first, so a + // still-pending tool-progress window is materialized and edited rather than + // missed (the on-off inconsistency). + const collapseProgressWindowIntoSummary = async (): Promise<"edited" | "posted" | "none"> => { const line = resolveProgressCollapseSummaryLine(); if (!line) { - return false; + return "none"; } const messageId = await answerLane.stream?.finalizeToPreview(renderStreamText(line)); if (typeof messageId === "number") { - return true; + return "edited"; } - // No live window to edit (rv mode, never rendered): keep the bar as a - // fresh durable post so the timeline still shows the collapse summary. + // No live window message existed to edit; still surface the bar, but never + // delete (there is nothing on screen to remove). await sendPayload({ text: line }, { durable: true }); - return false; + return "posted"; + }; + // Reset answer-lane bookkeeping after a bar was edited/posted in place, + // WITHOUT clear() — the window message stays (as the bar) and must not be + // deleted (no focus-jump). forceNewMessage only rewinds the stream so the + // next send starts a new message. + const resetAnswerLaneAfterCollapse = () => { + if (activeAnswerDraftIsToolProgressOnly) { + resetAnswerToolProgressDraft(); + suppressProgressDraftState(); + rotateAnswerLaneWhenQueuedBlocksSettle = false; + } + answerLane.stream?.forceNewMessage(); + resetDraftLaneState(answerLane); + }; + // Tear the window down (delete) — only when there is NO bar to keep it on + // screen for (error final, or a turn with nothing to summarize). A bar + // collapse never reaches here, so clear()/delete never runs when a bar + // exists (the on-off focus-jump). + const teardownProgressWindow = async () => { + if (activeAnswerDraftIsToolProgressOnly) { + await rotateAnswerLaneAfterToolProgress(); + } else { + await answerLane.stream?.clear(); + resetDraftLaneState(answerLane); + } }; const deliverProgressModeFinalAnswer = async ( payload: ReplyPayload, text: string, ): Promise => { - // Collapse the window into the bar in place BEFORE resetting lane state - // (which drops the stream's message id). Error finals get no summary - // (Discord parity). When nothing collapsed in place, tear the window down - // so a stale progress box does not linger above the final answer. - const collapsedInPlace = - payload.isError === true ? false : await collapseProgressWindowIntoSummary(); if (payload.isError === true) { + // Error finals get no collapse summary (Discord parity); tear down. progressSummaryDelivered = true; - } - if (!collapsedInPlace) { - if (activeAnswerDraftIsToolProgressOnly) { - await rotateAnswerLaneAfterToolProgress(); - } else { - await answerLane.stream?.clear(); - resetDraftLaneState(answerLane); - } + await teardownProgressWindow(); } else { - if (activeAnswerDraftIsToolProgressOnly) { - resetAnswerToolProgressDraft(); - suppressProgressDraftState(); - rotateAnswerLaneWhenQueuedBlocksSettle = false; + // Collapse BEFORE resetting lane state (which drops the stream's message + // id). "edited"/"posted" keep a bar on screen — reset without delete; + // "none" (nothing to summarize) tears the stale window down. + const outcome = await collapseProgressWindowIntoSummary(); + if (outcome === "none") { + await teardownProgressWindow(); + } else { + resetAnswerLaneAfterCollapse(); } - answerLane.stream?.forceNewMessage(); - resetDraftLaneState(answerLane); } const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true }); if (!delivered) { diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index 8785cc97148e..fe945d92d8b7 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -312,6 +312,50 @@ describe("createTelegramDraftStream", () => { expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); }); + it("finalizeToPreview edits the live window message in place without deleting", async () => { + const api = createMockDraftApi(); + const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" } }); + + stream.update("🛠️ Exec: pnpm test"); + await stream.flush(); + const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" }); + + expect(messageId).toBe(17); + // The window message is EDITED into the bar, never deleted (no focus-jump). + expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "🛠️ 1 tool call · ⏱️ 1s"); + expect(api.deleteMessage).not.toHaveBeenCalled(); + }); + + it("finalizeToPreview materializes a still-pending window before editing", async () => { + // A throttled preview may not have been sent yet when the collapse runs; + // finalizeToPreview must send it first so there is a message to edit into + // the bar, rather than returning undefined and forcing a delete + repost. + const api = createMockDraftApi(); + const stream = createDraftStream(api, { + thread: { id: 42, scope: "dm" }, + throttleMs: 10_000, + }); + + stream.update("🛠️ Exec: pnpm test"); + const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" }); + + expect(messageId).toBe(17); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expect(api.deleteMessage).not.toHaveBeenCalled(); + }); + + it("finalizeToPreview returns undefined when no window ever rendered", async () => { + const api = createMockDraftApi(); + const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" } }); + + const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" }); + + expect(messageId).toBeUndefined(); + expect(api.sendMessage).not.toHaveBeenCalled(); + expect(api.editMessageText).not.toHaveBeenCalled(); + expect(api.deleteMessage).not.toHaveBeenCalled(); + }); + it("deletes message preview on clear after finalization", async () => { vi.useFakeTimers(); try { diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 351905a6bcd4..7c58021b7f9d 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -597,13 +597,27 @@ export function createTelegramDraftStream(params: { const finalizeToPreview = async ( preview: TelegramDraftPreview, ): Promise => { + const text = preview.text.trimEnd(); + if (!text) { + return undefined; + } // Settle pending updates so we edit the real, current window message. streamState.final = true; await loop.flush(); - const text = preview.text.trimEnd(); - // No live window message to edit (never rendered, or already torn down): - // nothing to collapse in place — caller falls back to a fresh bar post. - if (typeof streamMessageId !== "number" || !text) { + // A throttled preview can still be pending (the last tool-progress line was + // coalesced and never sent), leaving no message id even though the window + // "rendered". Materialize it as a final flush would, so the window message + // exists and can be edited in place — otherwise on-off collapses missed it + // and fell back to a delete + repost. + if (typeof streamMessageId !== "number" && !streamState.stopped) { + const pending = lastRequestedText.trimEnd(); + if (pending && pending !== lastDeliveredText.trimEnd()) { + await sendOrEditStreamMessage(pending); + } + } + // Genuinely no live window message (rv mode never rendered): caller posts a + // fresh durable bar instead — but it must NOT delete anything. + if (typeof streamMessageId !== "number") { return undefined; } // Replace the whole message with the bar line: edits diff from a zero