refactor(telegram): remove obsolete progress summary collapse (#127089)

This commit is contained in:
Peter Steinberger
2026-08-21 01:19:49 -07:00
committed by GitHub
parent 0a0d343cf2
commit f28bb5e79d
13 changed files with 24 additions and 339 deletions
@@ -51,14 +51,11 @@ type TelegramProgressDraftState = {
export function createProgressState(
config: TurnConfig,
draftState: TelegramProgressDraftState,
getTurn: () => Turn,
prepareAnswerLaneForToolProgress: () => Promise<void>,
): TelegramProgressStateSlice {
const progressState = {
draftEverRendered: false,
finalAnswerDeliveryStarted: false,
finalAnswerDelivered: false,
sawProgressFinal: false,
verboseProgressActive: () => false,
};
const progressCompositor = createChannelProgressDraftCompositor({
@@ -80,7 +77,6 @@ export function createProgressState(
// headline/checklist mode, so they must not also arrive inside the text.
rendersRollingLinesNatively: true,
update: async (streamText, options) => {
getTurn().draftEverRendered = true;
await prepareAnswerLaneForToolProgress();
draftState.answerLane.lastPartialText = streamText;
draftState.answerLane.hasStreamedMessage = true;
@@ -159,7 +155,6 @@ export function markFinalStarted(turn: Turn): void {
export function markFinalDelivered(turn: Turn): void {
turn.finalAnswerDelivered = true;
turn.sawProgressFinal = true;
turn.progressCompositor.markFinalReplyDelivered();
}
@@ -372,8 +372,7 @@ export async function deliverReply(
turn.streamMode === "progress" &&
info.kind === "block" &&
effectivePayload.isCommentary === true;
// CLI finals exclude separately classified commentary. Send that block outside
// the disposable progress stream or its collapse summary erases the text.
// CLI finals exclude separately classified commentary, so it must outlive the progress draft.
const suppressProgressAnswerBlock =
turn.streamMode === "progress" &&
info.kind === "block" &&
@@ -241,7 +241,6 @@ export async function runTelegramDispatchTurn(turn: Turn) {
beginDraftQueuedFollowup(turn);
turn.finalAnswerDeliveryStarted = false;
turn.finalAnswerDelivered = false;
turn.sawProgressFinal = false;
turn.progressCompositor.beginNewTurn({ force: true });
},
onQueuedFollowupSettled: async () => {
@@ -314,9 +313,6 @@ export async function runTelegramDispatchTurn(turn: Turn) {
turn.agentRunFailed = readAgentRunTerminalOutcome(turnResult.dispatchResult) === "failed";
turn.noVisibleReplyFallbackEligible =
turnResult.dispatchResult.noVisibleReplyFallbackEligible === true;
if (hasFinalInboundReplyDispatch(turnResult.dispatchResult)) {
turn.sawProgressFinal = true;
}
turn.suppressSilentReplyFallback =
turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only";
return true;
@@ -1,14 +1,11 @@
import { dispatchReplyWithBufferedBlockDispatcher as dispatchReplyWithBufferedBlockDispatcherRuntime } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { expect, it, vi } from "vitest";
import { expectWindowRetiredAfterFinal } from "./bot-message-dispatch.progress-window.test-helpers.js";
import {
expectWindowRetiredAfterFinal,
expectWindowRetiredWithoutSummary,
} from "./bot-message-dispatch.progress-window.test-helpers.js";
import {
allDeliveredReplyTexts,
describeTelegramDispatch,
createContext,
createDirectSessionPayload,
createReasoningStreamContext,
createStatusReactionController,
createTelegramDraftStream,
deliverReplies,
@@ -592,7 +589,6 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
// A tool-only window retires by repositioning in place (not delete + repost
// — Discord parity), so clear() is never called on it.
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Branch is up to date" });
expectDeliverRepliesParams({ replyToMode: "off" });
// The final answer is SENT before the window retires: sending first keeps
@@ -619,7 +615,6 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
});
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Terminal block answer");
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
expectDeliveredReply(0, { text: "Terminal block answer" });
});
@@ -646,22 +641,11 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
expect.objectContaining({ text: expect.stringContaining("Exec") }),
);
expectDeliveredReply(0, { text: "Terminal block after tool" });
expectWindowRetiredWithoutSummary(answerDraftStream);
expectWindowRetiredAfterFinal(answerDraftStream, deliverReplies);
});
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 retiring the progress window", 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.
// Deliver first so removing the progress window cannot move the final off screen.
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
@@ -677,15 +661,11 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
telegramCfg: { streaming: { mode: "progress" } },
});
// Final delivered first, then the window retires behind it.
expectDeliveredReply(0, { text: "All done" });
expectWindowRetiredWithoutSummary(answerDraftStream);
expectWindowRetiredAfterFinal(answerDraftStream, deliverReplies);
});
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).
it("retires the progress window when the final answer send is skipped", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
deliverReplies.mockResolvedValue({ delivered: false });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -702,42 +682,12 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
telegramCfg: { streaming: { mode: "progress" } },
});
// The bar still edits the window in place even though the final send failed.
expectWindowRetiredWithoutSummary(answerDraftStream);
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
});
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" } },
});
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Done" });
});
it("does not post a collapse summary when no progress draft started", async () => {
it("delivers only the final answer 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 };
});
@@ -748,12 +698,10 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
telegramCfg: { streaming: { mode: "progress" } },
});
const texts = allDeliveredReplyTexts();
expect(texts.some((text) => text.includes("⏱️"))).toBe(false);
expect(texts).toContain("Just an answer");
expect(allDeliveredReplyTexts()).toEqual(["Just an answer"]);
});
it("does not post a collapse summary before an error final", async () => {
it("delivers only the error final after tool progress", async () => {
setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
@@ -772,7 +720,6 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
telegramCfg: { streaming: { mode: "progress" } },
});
const texts = allDeliveredReplyTexts();
expect(texts.some((text) => text.includes("tool call · ⏱️"))).toBe(false);
expect(allDeliveredReplyTexts()).toEqual(["Something went wrong"]);
});
});
@@ -1,5 +1,5 @@
import { expect, it, vi } from "vitest";
import { expectWindowRetiredWithoutSummary } from "./bot-message-dispatch.progress-window.test-helpers.js";
import { expectWindowRetiredAfterFinal } from "./bot-message-dispatch.progress-window.test-helpers.js";
import {
describeTelegramDispatch,
allDeliveredReplyTexts,
@@ -16,11 +16,9 @@ import {
} from "./bot-message-dispatch.test-harness.js";
import type { TelegramMessageContext } from "./bot-message-dispatch.test-harness.js";
describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
describeTelegramDispatch("dispatchTelegramMessage progress-lifecycle", () => {
it("keeps the progress window alive under /reasoning on so commentary and tools still stream", async () => {
// /reasoning on removes only the 🧠 lane from the window; commentary, tool
// lines, and the collapse bar must still stream (Discord parity). A prior
// regression forced block streaming in progress mode, killing the window.
// Durable reasoning removes only the reasoning lane, not commentary or tool progress.
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -40,26 +38,16 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
telegramCfg: { streaming: { mode: "progress", progress: { commentary: true } } },
});
// The window streamed (a preview was rendered) and collapsed into a bar
// counting the note + tool — proof the window was not killed.
expect(answerDraftStream.updatePreview).toHaveBeenCalled();
expectWindowRetiredWithoutSummary(answerDraftStream);
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.
it("retires a tool-progress-only window after durable reasoning and a mid-turn boundary", async () => {
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: "<think>hidden</think>", isReasoning: true },
{ kind: "block" },
@@ -79,21 +67,12 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
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.
expectWindowRetiredWithoutSummary(answerDraftStream);
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");
expect(allDeliveredReplyTexts()).toContain("Done");
});
it("keeps a single stationary window when text follows durable reasoning (no mid-turn rotation)", async () => {
// Single-message model (Discord parity): in progress mode the window is ONE
// message edited through every lane handover — durable 🧠, interim answer
// text — and edited into the bar only at collapse. It must NOT reposition or
// rotate mid-turn (no new bubble, no delete), which is what caused the churn
// and the on-off jump. Interim answer text does not render into the window.
// Interim answer text must not rotate or render into the progress window.
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -103,7 +82,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
{ text: "<think>hidden</think>", isReasoning: true },
{ kind: "block" },
);
// Interim answer text mid-turn: must not spawn a new window bubble.
await dispatcherOptions.deliver({ text: "Here is the answer" }, { kind: "block" });
await dispatcherOptions.deliver({ text: "Here is the answer." }, { kind: "final" });
return { queuedFinal: true };
@@ -118,20 +96,11 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
// The one window message stays put through the whole turn: no mid-turn
// reposition. It is retired once at end of turn, leaving the final answer as
// the only surviving message.
expect(answerDraftStream.rotateToNewMessageDeferringDelete).not.toHaveBeenCalled();
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
expectWindowRetiredWithoutSummary(answerDraftStream);
});
it("uses one stationary window message across a multi-boundary turn (commentary→tool→commentary→tool→final)", async () => {
// Single-message model (Discord parity): ONE window message id is created
// once and edited through every lane handover; it collapses into the bar in
// place at the end. Zero deletes in the happy path; the final is posted
// before the bar edit (task-9 order).
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
@@ -150,34 +119,22 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
telegramCfg: { streaming: { mode: "progress", progress: { commentary: true } } },
});
// The SAME window message id is used the whole turn — no new bubble.
const windowMessageIds = new Set(
answerDraftStream.updatePreview.mock.calls
.map(() => answerDraftStream.messageId())
.filter((id) => id != null),
);
expect(windowMessageIds).toEqual(new Set([2001]));
// The window was EDITED many times (once per lane change) ...
expect(answerDraftStream.updatePreview.mock.calls.length).toBeGreaterThan(1);
// A tool-only window is never deleted. It retires in place exactly once,
// after the final send, so the tool log survives with no mid-turn churn.
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Final answer" });
expect(requireInvocationOrder(deliverReplies, 0, "first reply delivery")).toBeLessThan(
requireInvocationOrder(
answerDraftStream.rotateToNewMessageDeferringDelete,
0,
"progress window retirement",
),
);
expectWindowRetiredAfterFinal(answerDraftStream, deliverReplies);
});
it("keeps Claude CLI pre-tool commentary after the progress window collapses", async () => {
it("keeps CLI pre-tool commentary after the progress window retires", async () => {
const markers = "Test markers: caribou-lampion-473, fromage-quantique, satellite-en-tricot";
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
expect(replyOptions?.commentaryPayloadsEnabled).toBe(true);
@@ -202,7 +159,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
expectWindowRetiredWithoutSummary(answerDraftStream);
expect(allDeliveredReplyTexts()).toEqual([markers, "TEST DONE"]);
});
@@ -235,15 +191,13 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
),
];
expect(windowTexts.some((text) => text.includes("Interim answer prose"))).toBe(false);
// The final answer is delivered below the collapsed window.
const delivered = allDeliveredReplyTexts();
expect(delivered).toContain("The real final answer.");
expect(delivered.some((text) => text.includes("Interim answer prose"))).toBe(false);
});
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.
// The durable verbose lane owns tool messages, so the progress window must not duplicate them.
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
@@ -260,12 +214,8 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
// No tool line ever rendered to the window (verbose owns it durably), so the
// window never streamed and there is no collapse bar to count it.
expect(answerDraftStream.updatePreview).not.toHaveBeenCalled();
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
const texts = allDeliveredReplyTexts();
expect(texts.some((text) => text.includes("tool call"))).toBe(false);
expect(allDeliveredReplyTexts()).toEqual(["Done"]);
});
it("replaces Telegram command progress items with matching command output", async () => {
@@ -340,8 +290,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
).toBeLessThan(
requireInvocationOrder(answerDraftStream.update, 0, "first answer draft update"),
);
// The window retires at end of turn; the final answer posts fresh below it.
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -346,9 +346,7 @@ describeTelegramDispatch("dispatchTelegramMessage progress-rendering", () => {
"<b>Shelling</b>\n<b>🔎 Web Search</b> <code>docs lookup</code>\n<b>Update</b> <code>tests passed</code>",
),
);
// A tool-progress-only window with nothing to summarize is torn down via the
// deferred-delete reposition (new content first, delete later), not a bare
// immediate clear/delete or forceNewMessage.
// Retire a tool-progress-only window by repositioning, with its delete deferred.
expect(draftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
expect(draftStream.clear).not.toHaveBeenCalled();
@@ -3,7 +3,6 @@ import {
isChannelPartialDeliveryError,
} from "openclaw/plugin-sdk/channel-inbound";
import { expect, it } from "vitest";
import { expectWindowRetiredWithoutSummary } from "./bot-message-dispatch.progress-window.test-helpers.js";
import {
appendAssistantMirrorMessageByIdentity,
type DispatchReplyWithBufferedBlockDispatcherArgs,
@@ -52,7 +51,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -83,7 +81,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -118,12 +115,11 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("uses the transcript final when progress-mode final text is truncated", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
setupDraftStreams({ answerMessageId: 2001 });
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
@@ -149,7 +145,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
expectWindowRetiredWithoutSummary(answerDraftStream);
expectDeliveredReply(0, { text: fullAnswer });
});
@@ -447,7 +442,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
expect(rollingPreview?.text).toContain(`command-${index}`);
}
expectDeliveredReply(0, { text: "Done" });
expectWindowRetiredWithoutSummary(draftStream);
});
it("renders command status without command output in Telegram progress draft previews", async () => {
@@ -587,7 +581,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
"<b>Shelling</b>\n<b>🧠 Thinking… (~200 tokens)</b>",
),
);
expectWindowRetiredWithoutSummary(draftStream);
expectDeliveredReply(0, { text: "Done" });
});
@@ -4,19 +4,6 @@ import { requireInvocationOrder } from "./bot-message-dispatch.test-harness.js";
type OrderedMock = { mock: { invocationCallOrder: number[] } };
/**
* Turn end retires the progress window: no synthesized activity digest is ever
* written back into it.
*/
export function expectWindowRetiredWithoutSummary(stream: {
finalizeToPreview: { mock: { calls: unknown[][] } };
}) {
const digests = stream.finalizeToPreview.mock.calls
.map((call) => (call[0] as { text?: string } | undefined)?.text ?? "")
.filter((text) => text.includes("⏱️"));
expect(digests).toEqual([]);
}
/**
* Retirement lands after the final, so shrinking the window above it never
* pushes the final off the anchored viewport. Text windows clear; tool-only
@@ -346,7 +346,6 @@ export const dispatchTelegramMessage = async (
const progressState = createProgressState(
turnConfig,
draftState,
() => turn,
async () => await prepareAnswerLaneForToolProgress(turn),
);
const deliveryState = createDeliveryState({ ...turnConfig, lanes: draftState.lanes }, () => turn);
@@ -394,8 +393,7 @@ export const dispatchTelegramMessage = async (
turn.dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
} finally {
// Terminal order: stop producers, drain queued drafts, materialize accepted text,
// clean previews, then collapse the progress window.
// Stop producers before draining drafts, finalizing accepted text, and cleaning previews.
turn.progressCompositor.cancel();
await waitForDraftEvents(turn);
try {
@@ -188,10 +188,8 @@ export type TelegramDraftStateSlice = {
};
export type TelegramProgressStateSlice = {
draftEverRendered: boolean;
finalAnswerDeliveryStarted: boolean;
finalAnswerDelivered: boolean;
sawProgressFinal: boolean;
verboseProgressActive: () => boolean;
progressCompositor: TelegramProgressCompositor;
commentaryProgressEnabled: boolean;
@@ -18,9 +18,6 @@ type TestDraftStream = {
clear: ReturnType<typeof vi.fn<() => Promise<void>>>;
stop: ReturnType<typeof vi.fn<() => Promise<void>>>;
discard: ReturnType<typeof vi.fn<() => Promise<void>>>;
finalizeToPreview: ReturnType<
typeof vi.fn<(preview: TelegramDraftPreview) => Promise<number | undefined>>
>;
forceNewMessage: ReturnType<typeof vi.fn<() => void>>;
rotateToNewMessageDeferringDelete: ReturnType<typeof vi.fn<() => number | undefined>>;
sendMayHaveLanded: ReturnType<typeof vi.fn<() => boolean>>;
@@ -88,14 +85,6 @@ export function createTestDraftStream(params?: {
}
await params?.onDiscard?.();
}),
finalizeToPreview: vi.fn().mockImplementation(async (preview: TelegramDraftPreview) => {
if (messageId == null) {
return undefined;
}
lastDeliveredText = preview.text.trimEnd();
stopped = true;
return messageId;
}),
forceNewMessage: vi.fn().mockImplementation(() => {
stopped = false;
if (params?.clearMessageIdOnForceNew) {
@@ -160,13 +149,6 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
discard: vi.fn().mockResolvedValue(undefined),
finalizeToPreview: vi.fn().mockImplementation(async (preview: TelegramDraftPreview) => {
if (activeMessageId == null) {
return undefined;
}
lastDeliveredText = preview.text.trimEnd();
return activeMessageId;
}),
forceNewMessage: vi.fn().mockImplementation(() => {
activeMessageId = undefined;
}),
@@ -460,99 +460,6 @@ describe("createTelegramDraftStream", () => {
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "see https://example.com now");
});
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("finalizeToPreview returns undefined when the in-place collapse edit does not apply", async () => {
// Red-team F2: a flood-wait (429) on the collapse edit makes the underlying
// send return false without applying. finalizeToPreview must report that as
// "not collapsed in place" (undefined) so the dispatch falls back to posting
// a durable bar — otherwise it assumes success, clears state, posts no bar,
// and the tall window is left on screen.
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
Object.assign(
new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 5)"),
{ error_code: 429, parameters: { retry_after: 5 } },
),
);
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).toBeUndefined();
expect(api.editMessageText).toHaveBeenCalledTimes(1);
// The live window is NOT deleted (the caller posts the bar below it instead).
expect(api.deleteMessage).not.toHaveBeenCalled();
});
it("does not replay a rejected pending edit after collapse fallback", async () => {
const api = createMockDraftApi();
const retryableEditError = () =>
Object.assign(new Error("429: retry after 1"), {
error_code: 429,
parameters: { retry_after: 1 },
});
const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" } });
stream.update("working");
await stream.flush();
api.editMessageText
.mockRejectedValueOnce(retryableEditError())
.mockRejectedValueOnce(retryableEditError());
stream.update("pending update");
const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" });
expect(messageId).toBeUndefined();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
await stream.stop();
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
});
it("deletes message preview on clear after finalization", async () => {
vi.useFakeTimers();
try {
-63
View File
@@ -66,13 +66,6 @@ export type TelegramDraftStream = {
remainingFinalContent?: () => TelegramDraftMessageSnapshot | undefined;
/** True while a pending or visible draft owns a first/batched reply target. */
hasConsumedReplyTarget?: () => boolean;
/**
* Collapse the preview in place: edit the existing window message so its
* content becomes `preview`, then stop without deleting. Used at end-of-turn
* so the streaming window becomes the summary bar (no delete + repost, which
* scroll-jumps the client). Returns the message id if the edit landed.
*/
finalizeToPreview: (preview: TelegramDraftPreview) => Promise<number | undefined>;
/** Reset internal state so the next update creates a new message instead of editing. */
forceNewMessage: () => void;
/**
@@ -918,61 +911,6 @@ export function createTelegramDraftStream(params: {
return undefined;
};
const finalizeToPreview = async (preview: TelegramDraftPreview): Promise<number | undefined> => {
const finalizeGeneration = generation;
const text = preview.text.trimEnd();
if (!text) {
return undefined;
}
// Settle pending updates so we edit the real, current window message.
streamState.final = true;
await flush();
if (generation !== finalizeGeneration) {
return undefined;
}
// 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()) {
const materialized = await sendOrEditStreamMessage(pending);
if (generation !== finalizeGeneration) {
return undefined;
}
if (materialized) {
loop.resetPending();
}
}
}
// 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;
}
// Collapse takes ownership of the live window. A stale throttled edit must
// not replay after either this edit or the caller's durable fallback.
loop.resetPending();
// Replace the whole message with the bar line.
finalPagePlan = undefined;
lastSentPreviewKey = "";
lastRequestedText = text;
lastRequestedPreview = { ...preview, text };
// The edit can fail to apply (flood-wait 429 or a terminal error both return
// false). Report that as "not collapsed in place" so the caller falls back to
// posting a durable bar instead of assuming the tall window became the bar.
const edited = await sendOrEditStreamMessage(text);
if (generation !== finalizeGeneration) {
return undefined;
}
streamState.stopped = true;
observeCurrentProviderMessage();
await drainProviderMessageObservations();
return edited ? streamMessageId : undefined;
};
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
@@ -993,7 +931,6 @@ export function createTelegramDraftStream(params: {
},
remainingFinalContent,
hasConsumedReplyTarget: () => replyTargetState.kind !== "available",
finalizeToPreview,
forceNewMessage: () => resetStreamToNewMessage(false, true),
rotateToNewMessageDeferringDelete,
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",