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