diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts
index 396954435891..12fb76eaebff 100644
--- a/extensions/telegram/src/bot-message-dispatch.test.ts
+++ b/extensions/telegram/src/bot-message-dispatch.test.ts
@@ -1812,9 +1812,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "partial" } },
});
- expect(mockCallArg(answerDraftStream.update)).toContain("Exec");
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Done ");
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Done answer");
+ expect(mockCallArg(answerDraftStream.updatePreview).text).toContain("Exec");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Done ");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Done answer");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("Done answer.");
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -2219,15 +2219,14 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site A shows X.");
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(
- 3,
- expect.stringMatching(/`đ ď¸ Exec`$/),
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: expect.stringMatching(/`đ ď¸ Exec`$/) }),
);
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(4, "Final answer");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
const progressResetOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
- const progressUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[2];
+ const progressUpdateOrder = answerDraftStream.updatePreview.mock.invocationCallOrder[0];
expect(progressResetOrder).toBeLessThan(progressUpdateOrder);
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -2247,12 +2246,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X.");
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(
- 2,
- expect.stringMatching(/`đ ď¸ Exec`$/),
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: expect.stringMatching(/`đ ď¸ Exec`$/) }),
);
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Site B shows Y.");
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(4, "Final answer");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site B shows Y.");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
expect(deliverReplies).not.toHaveBeenCalled();
@@ -2290,16 +2288,15 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(
- 1,
- expect.stringMatching(/`đ ď¸ Exec`$/),
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: expect.stringMatching(/`đ ď¸ Exec`$/) }),
);
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Branch is up to date");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
- const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
+ const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
expect(clearOrder).toBeLessThan(rotationOrder);
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
});
@@ -2317,16 +2314,15 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(
- 1,
- expect.stringMatching(/`đ ď¸ Exec`$/),
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: expect.stringMatching(/`đ ď¸ Exec`$/) }),
);
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Branch is up to date");
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
- const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
+ const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
expect(clearOrder).toBeLessThan(rotationOrder);
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
});
@@ -2373,9 +2369,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
- expect(answerDraftStream.update).toHaveBeenCalledWith(
- "Cracking\n\n`đ ď¸ Exec`\n\n`đ ď¸ git rev-parse --abbrev-ref HEAD`",
- );
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Cracking\n\n`đ ď¸ Exec`\n`đ ď¸ git rev-parse --abbrev-ref HEAD`",
+ richMessage: {
+ html: "Cracking
đ ď¸ Exec
đ ď¸ Exec git rev-parse --abbrev-ref HEAD",
+ skip_entity_detection: true,
+ },
+ });
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
@@ -2400,12 +2400,18 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
- expect(answerDraftStream.update).toHaveBeenCalledTimes(2);
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Cracking\n\n`đ ď¸ Exec`");
- expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, trailingFinalStatusText);
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Cracking\n\n`đ ď¸ Exec`",
+ richMessage: {
+ html: "Cracking
đ ď¸ Exec",
+ skip_entity_detection: true,
+ },
+ });
+ expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
+ expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, trailingFinalStatusText);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[1]).toBeLessThan(
- answerDraftStream.update.mock.invocationCallOrder[1],
+ answerDraftStream.update.mock.invocationCallOrder[0],
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2430,11 +2436,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
- expect(answerDraftStream.update).not.toHaveBeenCalledWith(
+ expect(answerDraftStream.updatePreview).not.toHaveBeenCalledWith(
expect.stringContaining("stdout line one"),
);
- expect(answerDraftStream.update).toHaveBeenLastCalledWith(
- "Shelling\n\n`đ ď¸ Exec`\n\n`đ Web Search: docs lookup`",
+ expect(answerDraftStream.updatePreview).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ text: "Shelling\n\n`đ ď¸ Exec`\n`đ Web Search: docs lookup`",
+ }),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -2456,8 +2464,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
- expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
- expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n\n`đ ď¸ Exec`");
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Shelling\n\n`đ ď¸ Exec`" }),
+ );
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2484,8 +2494,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
- expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
- expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n\n`đ ď¸ Exec`");
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Shelling\n\n`đ ď¸ Exec`" }),
+ );
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2516,8 +2528,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
- expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
- expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n\n`đ ď¸ Exec`");
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
+ expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Shelling\n\n`đ ď¸ Exec`" }),
+ );
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2648,7 +2662,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
- expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`đ ď¸ Exec`");
+ expect(draftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Shelling\n\n`đ ď¸ Exec`",
+ richMessage: {
+ html: "Shelling
đ ď¸ Exec",
+ skip_entity_detection: true,
+ },
+ });
expect(draftStream.flush).toHaveBeenCalled();
});
@@ -2670,7 +2690,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
- expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`đ ď¸ Exec`\n\n⢠_Checking files_");
+ expect(draftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Shelling\n\n`đ ď¸ Exec`\n⢠_Checking files_",
+ richMessage: {
+ html: "Shelling
đ ď¸ Exec
Checking files",
+ skip_entity_detection: true,
+ },
+ });
});
it("renders configured Telegram commentary progress from preamble item events", async () => {
@@ -2697,7 +2723,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
- expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n_Checking recent context_");
+ expect(draftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Shelling\n\n_Checking recent context_",
+ richMessage: {
+ html: "Shelling
Checking recent context",
+ skip_entity_detection: true,
+ },
+ });
});
it("suppresses Telegram preamble progress when commentary is disabled", async () => {
@@ -2724,7 +2756,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
- expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Checking recent"));
+ expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
+ expect.objectContaining({ text: expect.stringContaining("Checking recent") }),
+ );
});
it("keeps the progress draft label when tool progress lines are hidden", async () => {
@@ -2748,7 +2782,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
- expect(draftStream.update).toHaveBeenCalledWith("Shelling");
+ expect(draftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Shelling",
+ richMessage: { html: "Shelling", skip_entity_detection: true },
+ });
expect(draftStream.flush).toHaveBeenCalled();
});
@@ -2777,10 +2814,20 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
- await vi.waitFor(() => expect(draftStream.update).toHaveBeenCalledWith("Working"));
- expect(draftStream.update).not.toHaveBeenCalledWith("Working.");
- expect(draftStream.update).not.toHaveBeenCalledWith("Working..");
- expect(draftStream.update).not.toHaveBeenCalledWith("Working...");
+ await vi.waitFor(() =>
+ expect(draftStream.updatePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Working" }),
+ ),
+ );
+ expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Working." }),
+ );
+ expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Working.." }),
+ );
+ expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
+ expect.objectContaining({ text: "Working..." }),
+ );
finishRun?.();
await run;
});
@@ -2800,10 +2847,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
const pendingToolStart = replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await Promise.resolve();
await Promise.resolve();
- const updateBeforeStatusReaction = draftStream.update.mock.calls.at(-1)?.[0];
+ const updateBeforeStatusReaction = draftStream.updatePreview.mock.calls.at(-1)?.[0]?.text;
releaseSetTool?.();
await pendingToolStart;
- expect(updateBeforeStatusReaction).toMatch(/^Shelling\n`đ ď¸ Exec`$/);
+ expect(updateBeforeStatusReaction).toBe("Shelling\n\n`đ ď¸ Exec`");
return { queuedFinal: false };
});
@@ -2839,9 +2886,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
- expect(draftStream.update).toHaveBeenCalledWith(
- "Shelling\n\n`đ Web Search: docs lookup`\n\n⢠`tests passed`",
- );
+ expect(draftStream.updatePreview).toHaveBeenCalledWith({
+ text: "Shelling\n\n`đ Web Search: docs lookup`\n⢠`tests passed`",
+ richMessage: {
+ html: "Shelling
đ Web Search docs lookup
Update tests passed",
+ skip_entity_detection: true,
+ },
+ });
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(draftStream.materialize).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
@@ -3984,7 +4035,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "partial" } },
});
- expect(mockCallArg(answerDraftStream.update)).toContain("Exec");
+ expect(mockCallArg(answerDraftStream.updatePreview).text).toContain("Exec");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("block after progress");
});
diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts
index e95da3e01275..7d1c70e4a88c 100644
--- a/extensions/telegram/src/bot-message-dispatch.ts
+++ b/extensions/telegram/src/bot-message-dispatch.ts
@@ -25,11 +25,11 @@ import {
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/channel-outbound";
import {
+ buildChannelProgressDraftLine,
buildChannelProgressDraftLineForEntry,
type ChannelProgressDraftLine,
+ type ChannelProgressDraftCompositorLine,
createChannelProgressDraftCompositor,
- formatChannelProgressDraftLine,
- formatChannelProgressDraftLineForEntry,
resolveChannelStreamingBlockEnabled,
resolveTranscriptBackedChannelFinalText,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -100,7 +100,7 @@ import {
import type { TelegramStreamMode } from "./bot/types.js";
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
-import { createTelegramDraftStream } from "./draft-stream.js";
+import { createTelegramDraftStream, type TelegramDraftPreview } from "./draft-stream.js";
import {
buildTelegramErrorScopeKey,
isSilentErrorPolicy,
@@ -122,6 +122,7 @@ import {
splitTelegramReasoningText,
} from "./reasoning-lane-coordinator.js";
import {
+ buildTelegramRichHtml,
buildTelegramRichMarkdown,
splitTelegramRichMarkdownChunks,
TELEGRAM_RICH_TEXT_LIMIT,
@@ -405,6 +406,63 @@ function formatTelegramProgressLine(text: string): string {
: formatProgressAsMarkdownCode(text);
}
+function escapeTelegramProgressHtml(text: string): string {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+function renderTelegramProgressStringLine(text: string): string {
+ const clipped = clipProgressMarkdownText(text.trim());
+ const italic = clipped.match(/^_(.*)_$/u);
+ if (italic) {
+ return `${escapeTelegramProgressHtml(italic[1] ?? "")}`;
+ }
+ return `${escapeTelegramProgressHtml(clipped)}`;
+}
+
+function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): string {
+ if (typeof line === "string") {
+ return line.split(/\r?\n/u).map(renderTelegramProgressStringLine).filter(Boolean).join("
");
+ }
+ if (!line.icon && line.label === "Commentary") {
+ return renderTelegramProgressStringLine(line.text);
+ }
+ const label = [line.icon, line.label].filter(Boolean).join(" ");
+ const parts = [`${escapeTelegramProgressHtml(label)}`];
+ const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
+ if (detail) {
+ parts.push(`${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}`);
+ } else {
+ const text = line.text.trim();
+ if (text && text !== label) {
+ parts.push(renderTelegramProgressStringLine(text));
+ }
+ }
+ if (line.status && line.status !== line.detail) {
+ parts.push(`${escapeTelegramProgressHtml(line.status)}`);
+ }
+ return parts.join(" ");
+}
+
+function renderTelegramProgressDraftPreview(
+ text: string,
+ lines: readonly ChannelProgressDraftCompositorLine[],
+): TelegramDraftPreview {
+ const trimmed = text.trimEnd();
+ const [heading] = trimmed.split(/\r?\n/u, 1);
+ const renderedLines = lines.map(renderTelegramProgressLine).filter(Boolean);
+ const htmlParts = heading?.trim()
+ ? [`${escapeTelegramProgressHtml(heading.trim())}`, ...renderedLines]
+ : renderedLines;
+ return {
+ text: trimmed,
+ richMessage: buildTelegramRichHtml(htmlParts.join("
"), { skipEntityDetection: true }),
+ };
+}
+
function normalizeTelegramThreadId(value: unknown): number | undefined {
return parseStrictPositiveInteger(value);
}
@@ -967,16 +1025,14 @@ export const dispatchTelegramMessage = async ({
active: Boolean(answerLane.stream),
seed: progressSeed,
formatLine: formatTelegramProgressLine,
- // Telegram's rich-markdown renderer collapses a lone "\n" to a space, so
- // tool-progress lines need a blank line between them to stay on their own
- // line (it renders "\n\n" as a single break, not a double).
- lineSeparator: "\n\n",
update: async (streamText, options) => {
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
- answerLane.stream?.update(streamText);
+ answerLane.stream?.updatePreview(
+ renderTelegramProgressDraftPreview(streamText, options?.lines ?? []),
+ );
if (options?.flush) {
await answerLane.stream?.flush();
}
@@ -2280,7 +2336,7 @@ export const dispatchTelegramMessage = async ({
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
const progressPromise = pushStreamToolProgress(
- formatChannelProgressDraftLineForEntry(
+ buildChannelProgressDraftLineForEntry(
telegramCfg,
{
event: "tool",
@@ -2327,7 +2383,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
- formatChannelProgressDraftLine({
+ buildChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
@@ -2341,7 +2397,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
- formatChannelProgressDraftLine({
+ buildChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
@@ -2356,7 +2412,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
- formatChannelProgressDraftLine({
+ buildChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
@@ -2371,7 +2427,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
- formatChannelProgressDraftLine({
+ buildChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
diff --git a/extensions/telegram/src/draft-stream.test-helpers.ts b/extensions/telegram/src/draft-stream.test-helpers.ts
index 0cb75d5a7d3c..caf8ab7afd3f 100644
--- a/extensions/telegram/src/draft-stream.test-helpers.ts
+++ b/extensions/telegram/src/draft-stream.test-helpers.ts
@@ -3,6 +3,7 @@ import { vi } from "vitest";
type TestDraftStream = {
update: ReturnType void>>;
+ updatePreview: ReturnType void>>;
flush: ReturnType Promise>>;
messageId: ReturnType number | undefined>>;
visibleSinceMs: ReturnType number | undefined>>;
@@ -40,6 +41,14 @@ export function createTestDraftStream(params?: {
lastDeliveredText = text.trimEnd();
params?.onUpdate?.(text);
}),
+ updatePreview: vi.fn().mockImplementation((preview: { text: string }) => {
+ if (stopped) {
+ return;
+ }
+ previewRevision += 1;
+ lastDeliveredText = preview.text.trimEnd();
+ params?.onUpdate?.(preview.text);
+ }),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
@@ -86,6 +95,14 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
previewRevision += 1;
lastDeliveredText = text.trimEnd();
}),
+ updatePreview: vi.fn().mockImplementation((preview: { text: string }) => {
+ if (activeMessageId == null) {
+ activeMessageId = nextMessageId++;
+ visibleSinceMs = Date.now();
+ }
+ previewRevision += 1;
+ lastDeliveredText = preview.text.trimEnd();
+ }),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => activeMessageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts
index 9bc1e289e746..762ebdb0c967 100644
--- a/extensions/telegram/src/draft-stream.test.ts
+++ b/extensions/telegram/src/draft-stream.test.ts
@@ -582,6 +582,46 @@ describe("createTelegramDraftStream", () => {
});
});
+ it("uses caller-provided rich previews", async () => {
+ const api = createMockDraftApi();
+ const stream = createDraftStream(api);
+
+ stream.updatePreview({
+ text: "Shelling\n\n`đ ď¸ Exec`",
+ richMessage: {
+ html: "Shelling
đ ď¸ Exec",
+ skip_entity_detection: true,
+ },
+ });
+ await stream.flush();
+
+ expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
+ chat_id: 123,
+ rich_message: {
+ html: "Shelling
đ ď¸ Exec",
+ skip_entity_detection: true,
+ },
+ });
+
+ stream.updatePreview({
+ text: "Shelling\n\n`đ ď¸ Exec`\n⢠_Checking files_",
+ richMessage: {
+ html: "Shelling
đ ď¸ Exec
Checking files",
+ skip_entity_detection: true,
+ },
+ });
+ await stream.flush();
+
+ expect(api.raw.editMessageText).toHaveBeenCalledWith({
+ chat_id: 123,
+ message_id: 17,
+ rich_message: {
+ html: "Shelling
đ ď¸ Exec
Checking files",
+ skip_entity_detection: true,
+ },
+ });
+ });
+
it("keeps rich rendered previews above the old text-message limit", async () => {
const richApi = {
sendRichMessage: vi.fn(async () => ({ message_id: 17 })),
diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts
index c09af53e8fd4..6c294cc1a433 100644
--- a/extensions/telegram/src/draft-stream.ts
+++ b/extensions/telegram/src/draft-stream.ts
@@ -35,6 +35,7 @@ const MAX_PREVIEW_FLOOD_SUSPEND_MS = 60_000;
export type TelegramDraftStream = {
update: (text: string) => void;
+ updatePreview: (preview: TelegramDraftPreview) => void;
flush: () => Promise;
messageId: () => number | undefined;
visibleSinceMs?: () => number | undefined;
@@ -52,7 +53,7 @@ export type TelegramDraftStream = {
sendMayHaveLanded?: () => boolean;
};
-type TelegramDraftPreview = {
+export type TelegramDraftPreview = {
text: string;
richMessage: TelegramInputRichMessage;
};
@@ -144,6 +145,7 @@ export function createTelegramDraftStream(params: {
let lastSentPreviewKey = "";
let lastDeliveredText = "";
let lastRequestedText = "";
+ let lastRequestedPreview: TelegramDraftPreview | undefined;
let previewRevision = 0;
let generation = 0;
let deliveredTextOffset = 0;
@@ -230,7 +232,10 @@ export function createTelegramDraftStream(params: {
if (!currentText) {
return false;
}
- const rendered = renderTelegramDraftPreview(currentText, params.renderText);
+ const rendered =
+ deliveredTextOffset === 0 && lastRequestedPreview?.text === trimmed
+ ? lastRequestedPreview
+ : renderTelegramDraftPreview(currentText, params.renderText);
const renderedText = rendered.text.trimEnd();
const renderedPreview = { ...rendered, text: renderedText };
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
@@ -344,14 +349,27 @@ export function createTelegramDraftStream(params: {
sendOrEditStreamMessage,
});
- const update = (text: string) => {
+ const requestDraftUpdate = (text: string, preview?: TelegramDraftPreview) => {
if (streamState.stopped || streamState.final) {
return;
}
+ lastRequestedPreview = preview;
lastRequestedText = text;
updateDraft(text);
};
+ const update = (text: string) => {
+ requestDraftUpdate(text);
+ };
+
+ const updatePreview = (preview: TelegramDraftPreview) => {
+ const text = preview.text.trimEnd();
+ if (!text) {
+ return;
+ }
+ requestDraftUpdate(text, { ...preview, text });
+ };
+
const stop = async () => {
streamState.final = true;
await loop.flush();
@@ -383,6 +401,7 @@ export function createTelegramDraftStream(params: {
}
if (!options?.keepPending) {
loop.resetPending();
+ lastRequestedPreview = undefined;
}
loop.resetThrottleWindow();
};
@@ -422,6 +441,7 @@ export function createTelegramDraftStream(params: {
return {
update,
+ updatePreview,
flush: loop.flush,
messageId: () => streamMessageId,
visibleSinceMs: () => streamVisibleSinceMs,
diff --git a/src/channels/progress-draft-compositor.test.ts b/src/channels/progress-draft-compositor.test.ts
index 277f25c51427..c31ebb911268 100644
--- a/src/channels/progress-draft-compositor.test.ts
+++ b/src/channels/progress-draft-compositor.test.ts
@@ -18,7 +18,32 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushToolProgress("đ ď¸ Exec", { startImmediately: true });
- expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
+ expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
+ });
+
+ it("passes structured progress lines to draft updates", async () => {
+ const update = vi.fn();
+ const progress = createChannelProgressDraftCompositor({
+ entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
+ mode: "progress",
+ active: true,
+ seed: "test",
+ update,
+ });
+ const line = {
+ kind: "tool" as const,
+ text: "đ ď¸ Exec: git status",
+ label: "Exec",
+ icon: "đ ď¸",
+ detail: "git status",
+ };
+
+ await progress.pushToolProgress(line, { startImmediately: true });
+
+ expect(update).toHaveBeenCalledWith("Shelling\n\nđ ď¸ Exec: git status", {
+ flush: true,
+ lines: [line],
+ });
});
it("keeps reasoning details hidden when tool progress lines are hidden", async () => {
@@ -36,8 +61,8 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushToolProgress("đ ď¸ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Reading files");
- expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
- expect(update).not.toHaveBeenCalledWith(expect.stringContaining("Reading"), undefined);
+ expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
+ expect(update.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(true);
});
it("does not resurrect progress after suppression", async () => {
@@ -70,7 +95,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Reading");
await progress.pushReasoningProgress(" files");
- expect(update).toHaveBeenLastCalledWith("Shelling\n\nđ ď¸ Exec\n⢠_Reading files_", undefined);
+ expect(update).toHaveBeenLastCalledWith(
+ "Shelling\n\nđ ď¸ Exec\n⢠_Reading files_",
+ expect.objectContaining({
+ lines: ["đ ď¸ Exec", "_Reading files_"],
+ }),
+ );
});
it("resets reasoning deltas without clearing tool progress", async () => {
@@ -88,7 +118,12 @@ describe("createChannelProgressDraftCompositor", () => {
progress.resetReasoningProgress();
await progress.pushReasoningProgress("Now testing");
- expect(update).toHaveBeenLastCalledWith("Shelling\n\nđ ď¸ Exec\n⢠_Now testing_", undefined);
+ expect(update).toHaveBeenLastCalledWith(
+ "Shelling\n\nđ ď¸ Exec\n⢠_Now testing_",
+ expect.objectContaining({
+ lines: ["đ ď¸ Exec", "_Now testing_"],
+ }),
+ );
});
it("preserves tagged reasoning content without leaking tags", async () => {
@@ -104,7 +139,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushToolProgress("đ ď¸ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Checking filesFinal answer prose");
- expect(update).toHaveBeenLastCalledWith("Shelling\n\nđ ď¸ Exec\n⢠_Checking files_", undefined);
+ expect(update).toHaveBeenLastCalledWith(
+ "Shelling\n\nđ ď¸ Exec\n⢠_Checking files_",
+ expect.objectContaining({
+ lines: ["đ ď¸ Exec", "_Checking files_"],
+ }),
+ );
});
it("waits for complete reasoning tags before showing tagged progress", async () => {
@@ -138,7 +178,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Checking filesFinal answer prose");
- expect(update).toHaveBeenLastCalledWith("Shelling\n\nđ ď¸ Exec\n⢠_Checking files_", undefined);
+ expect(update).toHaveBeenLastCalledWith(
+ "Shelling\n\nđ ď¸ Exec\n⢠_Checking files_",
+ expect.objectContaining({
+ lines: ["đ ď¸ Exec", "_Checking files_"],
+ }),
+ );
});
it("keeps literal reasoning tags inside code blocks", async () => {
@@ -156,7 +201,9 @@ describe("createChannelProgressDraftCompositor", () => {
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\nđ ď¸ Exec\n⢠_```html literal ```_",
- undefined,
+ expect.objectContaining({
+ lines: ["đ ď¸ Exec", "_```html literal ```_"],
+ }),
);
});
@@ -174,7 +221,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Thinking\n\n_Reading_");
await progress.pushReasoningProgress("Thinking\n\n_Reading files_");
- expect(update).toHaveBeenLastCalledWith("Shelling\n\nđ ď¸ Exec\n⢠_Reading files_", undefined);
+ expect(update).toHaveBeenLastCalledWith(
+ "Shelling\n\nđ ď¸ Exec\n⢠_Reading files_",
+ expect.objectContaining({
+ lines: ["đ ď¸ Exec", "_Reading files_"],
+ }),
+ );
});
it("logs a timer-fired start failure via the gate's default boundary logger", async () => {
diff --git a/src/channels/progress-draft-compositor.ts b/src/channels/progress-draft-compositor.ts
index 20ab58bb18b3..cee2e9b8ca16 100644
--- a/src/channels/progress-draft-compositor.ts
+++ b/src/channels/progress-draft-compositor.ts
@@ -27,7 +27,11 @@ export type ChannelProgressDraftMode = StreamingMode;
export type ChannelProgressDraftCompositor = ReturnType<
typeof createChannelProgressDraftCompositor
>;
-type ProgressDraftLine = string | ChannelProgressDraftLine;
+export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
+export type ChannelProgressDraftUpdateOptions = {
+ flush?: boolean;
+ lines?: readonly ChannelProgressDraftCompositorLine[];
+};
/** Creates a stateful compositor for one streaming channel reply. */
export function createChannelProgressDraftCompositor(params: {
@@ -35,15 +39,12 @@ export function createChannelProgressDraftCompositor(params: {
mode: ChannelProgressDraftMode;
active: boolean;
seed: string;
- update: (text: string, options?: { flush?: boolean }) => Promise | void;
+ update: (text: string, options?: ChannelProgressDraftUpdateOptions) => Promise | void;
deleteCurrent?: () => Promise | void;
tryNativeUpdate?: (text: string) => Promise | boolean;
formatLine?: (line: string) => string;
- /** Separator between rendered draft lines; forwarded to the draft formatter.
- * Telegram passes "\n\n" because its renderer collapses a lone newline. */
- lineSeparator?: string;
- isEmptyLine?: (line: ProgressDraftLine | undefined) => boolean;
- shouldStartNow?: (line: ProgressDraftLine | undefined) => boolean;
+ isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
+ shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
}) {
const previewToolProgressEnabled =
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
@@ -56,7 +57,7 @@ export function createChannelProgressDraftCompositor(params: {
previewToolProgressEnabled,
});
let progressSuppressed = false;
- let lines: ProgressDraftLine[] = [];
+ let lines: ChannelProgressDraftCompositorLine[] = [];
let lastRenderedText = "";
let reasoningRawText = "";
let lastReasoningLine: string | undefined;
@@ -69,7 +70,6 @@ export function createChannelProgressDraftCompositor(params: {
lines: draftLines,
seed: params.seed,
formatLine: options?.formatted === false ? undefined : params.formatLine,
- lineSeparator: params.lineSeparator,
});
const clearProgressState = (suppressed: boolean) => {
@@ -89,7 +89,7 @@ export function createChannelProgressDraftCompositor(params: {
return false;
}
lastRenderedText = text;
- await params.update(text, options);
+ await params.update(text, { ...options, lines: [...lines] });
return true;
};
@@ -118,7 +118,7 @@ export function createChannelProgressDraftCompositor(params: {
};
const noteProgress = async (
- line?: ProgressDraftLine,
+ line?: ChannelProgressDraftCompositorLine,
options?: { toolName?: string; startImmediately?: boolean },
) => {
if (!params.active || finalReplyStarted || finalReplyDelivered) {
@@ -167,7 +167,7 @@ export function createChannelProgressDraftCompositor(params: {
return false;
}
lastRenderedText = text;
- await params.update(text);
+ await params.update(text, { lines: [...lines] });
return true;
}
if (options?.startImmediately || params.shouldStartNow?.(line)) {
diff --git a/src/channels/streaming.ts b/src/channels/streaming.ts
index c6473a78a026..0eb1971d34d8 100644
--- a/src/channels/streaming.ts
+++ b/src/channels/streaming.ts
@@ -1047,11 +1047,6 @@ export function formatChannelProgressDraftText(params: {
formatLine?: (line: string) => string;
/** Prefix used for plain progress lines that lack their own icon. */
bullet?: string;
- /** Separator between rendered progress lines. Defaults to a single newline.
- * Telegram's rich-markdown parser treats a lone "\n" as a soft break (space),
- * so its draft passes "\n\n"; channels where a single newline already breaks
- * the line (Discord) keep the default. */
- lineSeparator?: string;
}): string {
const rawLabel = resolveChannelProgressDraftLabel({
entry: params.entry,
@@ -1063,7 +1058,6 @@ export function formatChannelProgressDraftText(params: {
const maxLineChars = resolveChannelProgressDraftMaxLineChars(params.entry);
const formatLine = params.formatLine ?? ((line: string) => line);
const bullet = params.bullet ?? "â˘";
- const lineSeparator = params.lineSeparator ?? "\n";
const rawLines: Array = resolvedLabel
? [{ draftLabel: resolvedLabel }, ...params.lines]
: params.lines;
@@ -1096,7 +1090,7 @@ export function formatChannelProgressDraftText(params: {
});
const renderedLines = lines.map((line) => line.text).filter((line) => Boolean(line));
if (renderedLines.length > 1 && lines[0]?.isLabelLine) {
- return `${renderedLines[0]}\n\n${renderedLines.slice(1).join(lineSeparator)}`;
+ return `${renderedLines[0]}\n\n${renderedLines.slice(1).join("\n")}`;
}
- return renderedLines.join(lineSeparator);
+ return renderedLines.join("\n");
}