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"); }