fix(telegram): render progress drafts with clean HTML transport

Render Telegram progress draft rows with clean plain previews while preserving Telegram HTML formatting through parse_mode.

The progress HTML path now stays transport-owned, including richMessages=false progress messages, while debug/plain/sanitized text remains readable without raw markup.

Thanks @snowzlmbot!
This commit is contained in:
snowzlmbot
2026-06-22 08:24:44 +08:00
committed by GitHub
parent dbb58341b5
commit e37b0f8cd3
7 changed files with 238 additions and 252 deletions
@@ -69,6 +69,17 @@
"fileName"
]
},
"api": {
"emoji": "🌐",
"title": "API",
"detailKeys": [
"url",
"endpoint",
"path",
"method",
"name"
]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
@@ -381,6 +381,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
return expectRecordFields(mockCallArg(createTelegramDraftStream), expected);
}
function telegramProgressPreview(text: string, html: string) {
return {
text,
richMessage: { html: html.replaceAll("\n", "<br>"), skip_entity_detection: true },
};
}
function expectDeliverRepliesParams(expected: Record<string, unknown>, callIndex = 0) {
return expectRecordFields(mockCallArg(deliverReplies, callIndex), expected);
}
@@ -398,10 +405,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
return expectRecordFields(mockCallArg(dispatchReplyWithBufferedBlockDispatcher), expected);
}
function telegramHtmlPreview(html: string) {
return { text: html, parseMode: "HTML" as const };
}
function createContext(overrides?: Partial<TelegramMessageContext>): TelegramMessageContext {
const base = {
ctxPayload: {},
@@ -637,7 +640,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
chatId: 123,
thread: { id: 777, scope: "dm" },
minInitialChars: 30,
minInitialDelayMs: 5000,
});
expect(draftStream.update).toHaveBeenCalledWith("Hello");
const delivery = expectDeliverRepliesParams({ thread: { id: 777, scope: "dm" } });
@@ -2475,7 +2477,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site A shows X.");
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/<b>🛠️ Exec<\/b>$/) }),
expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
@@ -2502,7 +2504,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X.");
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/<b>🛠️ Exec<\/b>$/) }),
expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site B shows Y.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
@@ -2544,7 +2546,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/<b>🛠️ Exec<\/b>$/) }),
expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
@@ -2570,7 +2572,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/<b>🛠️ Exec<\/b>$/) }),
expect.objectContaining({ text: expect.stringMatching(/🛠️ Exec$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
@@ -2625,8 +2627,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview(
"<b>Cracking</b><br><b>🛠️ Exec</b><br><b>🛠️ Exec</b> <code>git rev-parse --abbrev-ref HEAD</code>",
telegramProgressPreview(
"Cracking\n\n🛠️ Exec\n🛠️ git rev-parse --abbrev-ref HEAD",
"<b>Cracking</b>\n<b>🛠️ Exec</b>\n<b>🛠️ Exec</b> <code>git rev-parse --abbrev-ref HEAD</code>",
),
);
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date");
@@ -2636,27 +2639,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("shows a stable progress placeholder for progress-mode answer activity", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Short" });
await replyOptions?.onPartialReply?.({ text: "Short answer" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Short");
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Short answer");
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b>"),
);
});
it("replaces Telegram command progress items with matching command output", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
@@ -2687,7 +2669,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(lastUpdate?.text).toContain("install dependencies");
expect(lastUpdate?.text).not.toContain("completed");
expect(lastUpdate).toEqual(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b> <code>install dependencies</code>"),
telegramProgressPreview(
"Shelling\n\n🛠️ install dependencies",
"<b>Shelling</b>\n<b>🛠️ Exec</b> <code>install dependencies</code>",
),
);
});
@@ -2709,7 +2694,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Cracking</b><br><b>🛠️ Exec</b>"),
telegramProgressPreview("Cracking\n\n🛠️ Exec", "<b>Cracking</b>\n<b>🛠️ Exec</b>"),
);
expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, trailingFinalStatusText);
@@ -2720,67 +2705,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("clears progress drafts before durable verbose tool output", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
replyOptions?.onVerboseProgressVisibility?.(() => true);
await dispatcherOptions.deliver(
{ text: "Tool output visible to Telegram" },
{ kind: "tool" },
);
await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "Tool output visible to Telegram" });
expectDeliveredReply(0, { text: "Final answer" }, 1);
expect(answerDraftStream.clear.mock.invocationCallOrder[0]).toBeLessThan(
deliverReplies.mock.invocationCallOrder[0],
);
});
it("clears progress drafts before visible tool artifacts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver(
{ mediaUrl: "https://example.com/validation.txt" },
{ kind: "tool" },
);
await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { mediaUrl: "https://example.com/validation.txt" });
expectDeliveredReply(0, { text: "Final answer" }, 1);
expect(answerDraftStream.clear.mock.invocationCallOrder[0]).toBeLessThan(
deliverReplies.mock.invocationCallOrder[0],
);
});
it("does not stream text-only tool results into progress drafts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -2806,12 +2730,39 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
expect(answerDraftStream.updatePreview).toHaveBeenLastCalledWith(
expect.objectContaining({
text: "<b>Shelling</b><br><b>🛠️ Exec</b><br><b>🔎 Web Search</b> <code>docs lookup</code>",
text: "Shelling\n\n🛠️ Exec\n🔎 Web Search: docs lookup",
}),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("renders api progress item edge cases as HTML transport previews", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onItemEvent?.({ kind: "api", progressText: "GET /v1/users" });
await replyOptions?.onItemEvent?.({
kind: "api",
name: "api",
progressText: "POST /v1/jobs",
});
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.updatePreview).toHaveBeenLastCalledWith(
telegramProgressPreview(
"Shelling\n\n🌐 API: GET /v1/users\n🌐 API: POST /v1/jobs",
"<b>Shelling</b>\n<b>🌐 API</b> <code>GET /v1/users</code>\n<b>🌐 API</b> <code>POST /v1/jobs</code>",
),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not restart progress drafts after final answer delivery", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -2831,7 +2782,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b>"),
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2861,7 +2812,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b>"),
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2895,7 +2846,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b>"),
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -3045,7 +2996,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b>"),
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expect(draftStream.flush).toHaveBeenCalled();
});
@@ -3085,8 +3036,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenLastCalledWith(
telegramHtmlPreview(
"<b>Shelling</b><br><b>🛠️ Exec</b> <code>command false</code> <i>exit 2</i>",
telegramProgressPreview(
"Shelling\n\n🛠️ exit 2; command false",
"<b>Shelling</b>\n<b>🛠️ Exec</b> <code>command false</code> <i>exit 2</i>",
),
);
});
@@ -3126,7 +3078,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenLastCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b> <code>exit 2</code>"),
telegramProgressPreview(
"Shelling\n\n🛠️ exit 2",
"<b>Shelling</b>\n<b>🛠️ Exec</b> <code>exit 2</code>",
),
);
});
@@ -3149,7 +3104,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>"),
telegramProgressPreview(
"Shelling\n\n🛠️ Exec\n• Checking files",
"<b>Shelling</b>\n<b>🛠️ Exec</b>\n<i>Checking files</i>",
),
);
});
@@ -3178,7 +3136,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview("<b>Shelling</b><br><i>Checking recent context</i>"),
telegramProgressPreview(
"Shelling\n\nChecking recent context",
"<b>Shelling</b>\n<i>Checking recent context</i>",
),
);
});
@@ -3234,10 +3195,41 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(telegramHtmlPreview("<b>Shelling</b>"));
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling", "<b>Shelling</b>"),
);
expect(draftStream.flush).toHaveBeenCalled();
});
it.each([{ label: false }, { label: "Shelling", maxLines: 1 }] as const)(
"does not duplicate Telegram progress HTML rows without a visible label",
async (progress) => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress,
},
},
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("🛠️ Exec", "<b>🛠️ Exec</b>"),
);
},
);
it("keeps progress draft labels static while the draft is active", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -3264,17 +3256,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
await vi.waitFor(() =>
expect(draftStream.updatePreview).toHaveBeenCalledWith(telegramHtmlPreview("<b>Working</b>")),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
telegramHtmlPreview("<b>Working.</b>"),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
telegramHtmlPreview("<b>Working..</b>"),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
telegramHtmlPreview("<b>Working...</b>"),
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Working", "<b>Working</b>"),
),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith({ text: "Working." });
expect(draftStream.updatePreview).not.toHaveBeenCalledWith({ text: "Working.." });
expect(draftStream.updatePreview).not.toHaveBeenCalledWith({ text: "Working..." });
finishRun?.();
await run;
});
@@ -3297,7 +3285,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
const updateBeforeStatusReaction = draftStream.updatePreview.mock.calls.at(-1)?.[0]?.text;
releaseSetTool?.();
await pendingToolStart;
expect(updateBeforeStatusReaction).toBe("<b>Shelling</b><br><b>🛠️ Exec</b>");
expect(updateBeforeStatusReaction).toBe("Shelling\n\n🛠️ Exec");
return { queuedFinal: false };
});
@@ -3334,8 +3322,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramHtmlPreview(
"<b>Shelling</b><br><b>🔎 Web Search</b> <code>docs lookup</code><br><b>Update</b> <code>tests passed</code>",
telegramProgressPreview(
"Shelling\n\n🔎 Web Search: docs lookup\n• tests passed",
"<b>Shelling</b>\n<b>🔎 Web Search</b> <code>docs lookup</code>\n<b>Update</b> <code>tests passed</code>",
),
);
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
+41 -42
View File
@@ -30,6 +30,7 @@ import {
type ChannelProgressDraftLine,
type ChannelProgressDraftCompositorLine,
createChannelProgressDraftCompositor,
resolveChannelProgressDraftLabel,
resolveChannelStreamingBlockEnabled,
resolveTranscriptBackedChannelFinalText,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -152,7 +153,6 @@ const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-d
/** Minimum chars before sending first streaming message (improves push notification UX) */
const DRAFT_MIN_INITIAL_CHARS = 30;
const DRAFT_MIN_INITIAL_DELAY_MS = 5_000;
type DraftPartialTextUpdate = {
text: string;
@@ -434,18 +434,6 @@ function sanitizeProgressMarkdownText(text: string): string {
return text.replaceAll("`", "'");
}
function formatProgressAsMarkdownCode(text: string): string {
const clipped = clipProgressMarkdownText(text);
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
}
function formatTelegramProgressLine(text: string): string {
const trimmed = text.trim();
return trimmed.startsWith("_") && trimmed.endsWith("_")
? trimmed
: formatProgressAsMarkdownCode(text);
}
function escapeTelegramProgressHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
@@ -454,21 +442,39 @@ function escapeTelegramProgressHtml(text: string): string {
.replaceAll('"', "&quot;");
}
function renderTelegramProgressStringLine(text: string): string {
const clipped = clipProgressMarkdownText(text.trim());
function normalizeTelegramProgressText(text: string, options?: { trim?: boolean }): string {
const source = options?.trim === false ? text : text.trim();
const clipped = clipProgressMarkdownText(source);
const italic = clipped.match(/^_(.*)_$/u);
if (italic) {
return `<i>${escapeTelegramProgressHtml(italic[1] ?? "")}</i>`;
return italic[1] ?? "";
}
return `<code>${escapeTelegramProgressHtml(clipped)}</code>`;
return clipped;
}
function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): string {
function formatTelegramProgressLine(text: string): string {
return sanitizeProgressMarkdownText(normalizeTelegramProgressText(text, { trim: false }));
}
function renderTelegramProgressHtmlStringLine(text: string): string {
const normalized = normalizeTelegramProgressText(text);
const italic = text.trim().match(/^_(.*)_$/u);
if (italic) {
return `<i>${escapeTelegramProgressHtml(normalized)}</i>`;
}
return `<code>${escapeTelegramProgressHtml(normalized)}</code>`;
}
function renderTelegramProgressHtmlLine(line: ChannelProgressDraftCompositorLine): string {
if (typeof line === "string") {
return line.split(/\r?\n/u).map(renderTelegramProgressStringLine).filter(Boolean).join("<br>");
return line
.split(/\r?\n/u)
.map(renderTelegramProgressHtmlStringLine)
.filter(Boolean)
.join("\n");
}
if (!line.icon && line.label === "Commentary") {
return renderTelegramProgressStringLine(line.text);
return renderTelegramProgressHtmlStringLine(line.text);
}
const label = [line.icon, line.label].filter(Boolean).join(" ");
const parts = [`<b>${escapeTelegramProgressHtml(label)}</b>`];
@@ -478,7 +484,7 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
} else {
const text = line.text.trim();
if (text && text !== label) {
parts.push(renderTelegramProgressStringLine(text));
parts.push(renderTelegramProgressHtmlStringLine(text));
}
}
if (line.status && line.status !== "completed" && line.status !== line.detail) {
@@ -490,18 +496,19 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
function renderTelegramProgressDraftPreview(
text: string,
lines: readonly ChannelProgressDraftCompositorLine[],
richMessages: boolean,
label: string | undefined,
): TelegramDraftPreview {
const trimmed = text.trimEnd();
const [heading] = trimmed.split(/\r?\n/u, 1);
const renderedLines = lines.map(renderTelegramProgressLine).filter(Boolean);
const htmlParts = heading?.trim()
? [`<b>${escapeTelegramProgressHtml(heading.trim())}</b>`, ...renderedLines]
: renderedLines;
const html = htmlParts.join("<br>");
if (!richMessages) {
return { text: html, parseMode: "HTML" };
}
const textLines = trimmed.split(/\r?\n/u);
const labelVisible =
label !== undefined && (trimmed === label || (textLines[0] === label && textLines[1] === ""));
const bodyLines = labelVisible ? textLines.slice(textLines[1] === "" ? 2 : 1) : textLines;
const renderedLines = lines.map(renderTelegramProgressHtmlLine).filter(Boolean);
const visibleLines = renderedLines.slice(-bodyLines.filter(Boolean).length);
const htmlParts = labelVisible
? [`<b>${escapeTelegramProgressHtml(label)}</b>`, ...visibleLines]
: visibleLines;
const html = htmlParts.join("\n");
return {
text: trimmed,
richMessage: buildTelegramRichHtml(html, { skipEntityDetection: true }),
@@ -1029,7 +1036,6 @@ export const dispatchTelegramMessage = async ({
replyToMessageId: draftReplyToMessageId,
richMessages: telegramCfg.richMessages,
minInitialChars: draftMinInitialChars,
minInitialDelayMs: draftMinInitialChars > 0 ? DRAFT_MIN_INITIAL_DELAY_MS : undefined,
renderText: renderStreamText,
onSupersededPreview: (superseded) => {
if (superseded.retain) {
@@ -1107,7 +1113,7 @@ export const dispatchTelegramMessage = async ({
renderTelegramProgressDraftPreview(
streamText,
options?.lines ?? [],
telegramCfg.richMessages === true,
resolveChannelProgressDraftLabel({ entry: telegramCfg, seed: progressSeed }),
),
);
if (options?.flush) {
@@ -1389,7 +1395,7 @@ export const dispatchTelegramMessage = async ({
recomputeQueuedAnswerBlockRotations();
}
};
const updateDraftFromPartial = async (lane: DraftLaneState, update: DraftPartialTextUpdate) => {
const updateDraftFromPartial = (lane: DraftLaneState, update: DraftPartialTextUpdate) => {
const laneStream = lane.stream;
if (!laneStream || !update.text) {
return;
@@ -1401,7 +1407,6 @@ export const dispatchTelegramMessage = async ({
}
if (lane === answerLane) {
if (streamMode === "progress") {
await progressDraft.noteActivity();
return;
}
resetAnswerToolProgressDraft();
@@ -1428,7 +1433,7 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.noteReasoningHint();
reasoningStepState.noteReasoningDelivered();
}
await updateDraftFromPartial(lanes[segment.lane], segment.update);
updateDraftFromPartial(lanes[segment.lane], segment.update);
}
};
const flushDraftLane = async (lane: DraftLaneState) => {
@@ -2145,9 +2150,6 @@ export const dispatchTelegramMessage = async ({
}
if (segment.lane === "answer" && info.kind === "tool") {
if (verboseProgressActive()) {
if (streamMode === "progress") {
await rotateAnswerLaneAfterToolProgress();
}
if (
await sendPayload(
applyTextToPayload(effectivePayload, segment.update.text),
@@ -2299,9 +2301,6 @@ export const dispatchTelegramMessage = async ({
}
return;
}
if (streamMode === "progress" && info.kind === "tool") {
await rotateAnswerLaneAfterToolProgress();
}
const delivered = await sendPayload(effectivePayload, {
durable: info.kind === "final",
});
+63 -40
View File
@@ -573,13 +573,13 @@ describe("createTelegramDraftStream", () => {
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
html: "<b>Shelling</b>\n<b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>Shelling</b><br><b>🛠️ Exec</b>", {
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>Shelling</b>\n<b>🛠️ Exec</b>", {
parse_mode: "HTML",
});
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
@@ -587,7 +587,7 @@ describe("createTelegramDraftStream", () => {
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
html: "<b>Shelling</b>\n<b>🛠️ Exec</b>\n<i>Checking files</i>",
skip_entity_detection: true,
},
});
@@ -596,12 +596,70 @@ describe("createTelegramDraftStream", () => {
expect(api.editMessageText).toHaveBeenCalledWith(
123,
17,
"<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
"<b>Shelling</b>\n<b>🛠️ Exec</b>\n<i>Checking files</i>",
{ parse_mode: "HTML" },
);
expect(api.raw.editMessageText).not.toHaveBeenCalled();
});
it("sends marked progress rich previews through HTML text transport", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api);
stream.updatePreview({
text: "Shelling\n\n🛠️ Exec",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>Shelling</b>\n<b>🛠️ Exec</b>", {
parse_mode: "HTML",
});
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
stream.updatePreview({
text: "Shelling\n\n🛠️ Exec\n• Checking files",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><b>Update</b> <code>Checking files</code>",
skip_entity_detection: true,
},
});
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(
123,
17,
"<b>Shelling</b>\n<b>🛠️ Exec</b>\n<b>Update</b> <code>Checking files</code>",
{ parse_mode: "HTML" },
);
expect(api.raw.editMessageText).not.toHaveBeenCalled();
});
it("falls back to plain preview text when rich preview HTML parsing fails", async () => {
const api = createMockDraftApi();
api.sendMessage
.mockRejectedValueOnce(new Error("can't parse entities: unsupported tag"))
.mockResolvedValueOnce({ message_id: 17 });
const stream = createDraftStream(api);
stream.updatePreview({
text: "Shelling\n\n🛠️ Exec",
richMessage: {
html: "<b>Shelling</b>\n<b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
await stream.flush();
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "<b>Shelling</b>\n<b>🛠️ Exec</b>", {
parse_mode: "HTML",
});
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Shelling\n\n🛠️ Exec", {});
});
it("uses rich send and edit for previews when explicitly enabled", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true });
@@ -843,16 +901,11 @@ describe("createTelegramDraftStream", () => {
describe("draft stream initial message debounce", () => {
const createMockApi = () => createMockDraftApi(async () => ({ message_id: 42 }));
function createDebouncedStream(
api: ReturnType<typeof createMockApi>,
minInitialChars = 30,
minInitialDelayMs?: number,
) {
function createDebouncedStream(api: ReturnType<typeof createMockApi>, minInitialChars = 30) {
return createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars,
minInitialDelayMs,
});
}
@@ -921,36 +974,6 @@ describe("draft stream initial message debounce", () => {
expect(api.sendMessage).toHaveBeenCalled();
});
it("materializes a short first message after the initial delay", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api, 30, 5000);
stream.update("Processing");
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5000);
expectPreviewSend(api, "Processing");
});
it("cancels a delayed first message when clear() removes the draft", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api, 30, 5000);
stream.update("Processing");
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(1);
await stream.clear();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("works with longer text above threshold", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
+8 -51
View File
@@ -88,12 +88,16 @@ function isTelegramHtmlParseError(err: unknown): boolean {
return TELEGRAM_PARSE_ERR_RE.test(formatErrorMessage(err));
}
function telegramRichHtmlToParseModeHtml(html: string): string {
return html.replace(/<br\s*\/?>/giu, "\n");
}
function normalizeTelegramDraftTransportPreview(
preview: TelegramDraftPreview,
): TelegramDraftTransportPreview {
if (preview.richMessage?.html) {
return {
text: preview.richMessage.html,
text: telegramRichHtmlToParseModeHtml(preview.richMessage.html),
parseMode: "HTML",
plainText: preview.text,
};
@@ -178,8 +182,6 @@ export function createTelegramDraftStream(params: {
throttleMs?: number;
/** Minimum chars before sending first message (debounce for push notifications) */
minInitialChars?: number;
/** Maximum time to hold a short first preview before materializing it anyway. */
minInitialDelayMs?: number;
/** Optional preview renderer (e.g. markdown -> HTML + parse mode). */
renderText?: (text: string) => TelegramDraftPreview;
/** Called when a late send resolves after forceNewMessage() switched generations. */
@@ -192,7 +194,6 @@ export function createTelegramDraftStream(params: {
const maxChars = Math.min(params.maxChars ?? transportLimit, transportLimit);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const minInitialChars = params.minInitialChars;
const minInitialDelayMs = params.minInitialDelayMs;
const chatId = params.chatId;
const threadParams = buildTelegramThreadParams(params.thread);
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
@@ -227,8 +228,6 @@ export function createTelegramDraftStream(params: {
let lastDeliveredText = "";
let lastRequestedText = "";
let lastRequestedPreview: TelegramDraftPreview | undefined;
let firstShortPreviewSeenMs: number | undefined;
let initialPreviewTimer: ReturnType<typeof setTimeout> | undefined;
let previewRevision = 0;
let generation = 0;
let deliveredTextOffset = 0;
@@ -324,26 +323,6 @@ export function createTelegramDraftStream(params: {
streamVisibleSinceMs = visibleSinceMs;
return true;
};
const clearInitialPreviewTimer = () => {
if (initialPreviewTimer) {
clearTimeout(initialPreviewTimer);
initialPreviewTimer = undefined;
}
};
const scheduleInitialPreviewFlush = (delayMs: number) => {
if (initialPreviewTimer) {
return;
}
initialPreviewTimer = setTimeout(
() => {
initialPreviewTimer = undefined;
void flushInitialPreview().catch((err: unknown) => {
params.warn?.(`telegram stream preview delayed send failed: ${formatErrorMessage(err)}`);
});
},
Math.max(0, delayMs),
);
};
const stopOversizedPreview = (payloadLength: number): false => {
streamState.stopped = true;
params.warn?.(`telegram stream preview stopped (text length ${payloadLength} > ${maxChars})`);
@@ -376,8 +355,7 @@ export function createTelegramDraftStream(params: {
const renderedPayloadLength = richMessages
? telegramDraftRichPayloadLength(rendered)
: renderedText.length;
const renderedPreview = { ...rendered, text: renderedText };
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
const renderedPreviewKey = telegramDraftPreviewKey({ ...rendered, text: renderedText });
if (!renderedText) {
return false;
}
@@ -430,31 +408,15 @@ export function createTelegramDraftStream(params: {
if (typeof streamMessageId !== "number" && minInitialChars != null && !streamState.final) {
if (renderedText.length < minInitialChars) {
if (minInitialDelayMs == null) {
return false;
}
const now = Date.now();
firstShortPreviewSeenMs ??= now;
const remainingDelayMs = minInitialDelayMs - (now - firstShortPreviewSeenMs);
if (remainingDelayMs > 0) {
scheduleInitialPreviewFlush(remainingDelayMs);
return false;
}
clearInitialPreviewTimer();
} else {
firstShortPreviewSeenMs = undefined;
clearInitialPreviewTimer();
return false;
}
} else {
firstShortPreviewSeenMs = undefined;
clearInitialPreviewTimer();
}
const previousSentPreviewKey = lastSentPreviewKey;
lastSentPreviewKey = renderedPreviewKey;
try {
const sent = await sendMessageTransportPreview({
preview: renderedPreview,
preview: rendered,
sendGeneration,
});
if (sent) {
@@ -508,7 +470,6 @@ export function createTelegramDraftStream(params: {
state: streamState,
sendOrEditStreamMessage,
});
const flushInitialPreview = loop.flush;
const requestDraftUpdate = (text: string, preview?: TelegramDraftPreview) => {
if (streamState.stopped || streamState.final) {
@@ -555,8 +516,6 @@ export function createTelegramDraftStream(params: {
messageSendAttempted = false;
streamMessageId = undefined;
streamVisibleSinceMs = undefined;
firstShortPreviewSeenMs = undefined;
clearInitialPreviewTimer();
lastSentPreviewKey = "";
if (options?.resetOffset !== false) {
deliveredTextOffset = 0;
@@ -570,7 +529,6 @@ export function createTelegramDraftStream(params: {
};
const clear = async () => {
clearInitialPreviewTimer();
const messageId = await takeMessageIdAfterStop({
stopForClear,
readMessageId: () => streamMessageId,
@@ -589,7 +547,6 @@ export function createTelegramDraftStream(params: {
};
const discard = async () => {
clearInitialPreviewTimer();
await stopForClear();
};
+5
View File
@@ -74,6 +74,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
title: "Attach",
detailKeys: ["path", "url", "fileName"],
},
api: {
emoji: "🌐",
title: "API",
detailKeys: ["url", "endpoint", "path", "method", "name"],
},
browser: {
emoji: "🌐",
title: "Browser",
+2
View File
@@ -372,6 +372,8 @@ function itemKindToToolName(kind: string | undefined): string | undefined {
return "apply_patch";
case "search":
return "web_search";
case "api":
return "api";
case "tool":
return "tool_call";
default: