diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
index 86d54b853e6d..9d9cf366f373 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
@@ -69,6 +69,17 @@
"fileName"
]
},
+ "api": {
+ "emoji": "š",
+ "title": "API",
+ "detailKeys": [
+ "url",
+ "endpoint",
+ "path",
+ "method",
+ "name"
+ ]
+ },
"browser": {
"emoji": "š",
"title": "Browser",
diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts
index 09c05bd340a7..6099c6114f32 100644
--- a/extensions/telegram/src/bot-message-dispatch.test.ts
+++ b/extensions/telegram/src/bot-message-dispatch.test.ts
@@ -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", "
"), skip_entity_detection: true },
+ };
+ }
+
function expectDeliverRepliesParams(expected: Record, 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 {
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(/š ļø 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(/š ļø 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(/š ļø 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(/š ļø 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(
- "Cracking
š ļø Exec
š ļø Exec git rev-parse --abbrev-ref HEAD",
+ telegramProgressPreview(
+ "Cracking\n\nš ļø Exec\nš ļø git rev-parse --abbrev-ref HEAD",
+ "Cracking\nš ļø Exec\nš ļø Exec git rev-parse --abbrev-ref HEAD",
),
);
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("Shelling"),
- );
- });
-
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("Shelling
š ļø Exec install dependencies"),
+ telegramProgressPreview(
+ "Shelling\n\nš ļø install dependencies",
+ "Shelling\nš ļø Exec install dependencies",
+ ),
);
});
@@ -2709,7 +2694,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
- telegramHtmlPreview("Cracking
š ļø Exec"),
+ telegramProgressPreview("Cracking\n\nš ļø Exec", "Cracking\nš ļø Exec"),
);
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("Shelling
š ļø Exec"),
- );
- 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("Shelling
š ļø Exec"),
- );
- 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: "Shelling
š ļø Exec
š Web Search docs lookup",
+ 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",
+ "Shelling\nš API GET /v1/users\nš API POST /v1/jobs",
+ ),
+ );
+ 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("Shelling
š ļø Exec"),
+ telegramProgressPreview("Shelling\n\nš ļø Exec", "Shelling\nš ļø Exec"),
);
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("Shelling
š ļø Exec"),
+ telegramProgressPreview("Shelling\n\nš ļø Exec", "Shelling\nš ļø Exec"),
);
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("Shelling
š ļø Exec"),
+ telegramProgressPreview("Shelling\n\nš ļø Exec", "Shelling\nš ļø Exec"),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -3045,7 +2996,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
- telegramHtmlPreview("Shelling
š ļø Exec"),
+ telegramProgressPreview("Shelling\n\nš ļø Exec", "Shelling\nš ļø Exec"),
);
expect(draftStream.flush).toHaveBeenCalled();
});
@@ -3085,8 +3036,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenLastCalledWith(
- telegramHtmlPreview(
- "Shelling
š ļø Exec command false exit 2",
+ telegramProgressPreview(
+ "Shelling\n\nš ļø exit 2; command false",
+ "Shelling\nš ļø Exec command false exit 2",
),
);
});
@@ -3126,7 +3078,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenLastCalledWith(
- telegramHtmlPreview("Shelling
š ļø Exec exit 2"),
+ telegramProgressPreview(
+ "Shelling\n\nš ļø exit 2",
+ "Shelling\nš ļø Exec exit 2",
+ ),
);
});
@@ -3149,7 +3104,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
expect(draftStream.updatePreview).toHaveBeenCalledWith(
- telegramHtmlPreview("Shelling
š ļø Exec
Checking files"),
+ telegramProgressPreview(
+ "Shelling\n\nš ļø Exec\n⢠Checking files",
+ "Shelling\nš ļø Exec\nChecking files",
+ ),
);
});
@@ -3178,7 +3136,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
- telegramHtmlPreview("Shelling
Checking recent context"),
+ telegramProgressPreview(
+ "Shelling\n\nChecking recent context",
+ "Shelling\nChecking recent context",
+ ),
);
});
@@ -3234,10 +3195,41 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
- expect(draftStream.updatePreview).toHaveBeenCalledWith(telegramHtmlPreview("Shelling"));
+ expect(draftStream.updatePreview).toHaveBeenCalledWith(
+ telegramProgressPreview("Shelling", "Shelling"),
+ );
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", "š ļø Exec"),
+ );
+ },
+ );
+
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("Working")),
- );
- expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
- telegramHtmlPreview("Working."),
- );
- expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
- telegramHtmlPreview("Working.."),
- );
- expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
- telegramHtmlPreview("Working..."),
+ expect(draftStream.updatePreview).toHaveBeenCalledWith(
+ telegramProgressPreview("Working", "Working"),
+ ),
);
+ 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("Shelling
š ļø Exec");
+ expect(updateBeforeStatusReaction).toBe("Shelling\n\nš ļø Exec");
return { queuedFinal: false };
});
@@ -3334,8 +3322,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
- telegramHtmlPreview(
- "Shelling
š Web Search docs lookup
Update tests passed",
+ telegramProgressPreview(
+ "Shelling\n\nš Web Search: docs lookup\n⢠tests passed",
+ "Shelling\nš Web Search docs lookup\nUpdate tests passed",
),
);
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts
index d2f0c04e98f7..2b88de8c384d 100644
--- a/extensions/telegram/src/bot-message-dispatch.ts
+++ b/extensions/telegram/src/bot-message-dispatch.ts
@@ -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("&", "&")
@@ -454,21 +442,39 @@ function escapeTelegramProgressHtml(text: string): string {
.replaceAll('"', """);
}
-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 `${escapeTelegramProgressHtml(italic[1] ?? "")}`;
+ return italic[1] ?? "";
}
- return `${escapeTelegramProgressHtml(clipped)}`;
+ 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 `${escapeTelegramProgressHtml(normalized)}`;
+ }
+ return `${escapeTelegramProgressHtml(normalized)}`;
+}
+
+function renderTelegramProgressHtmlLine(line: ChannelProgressDraftCompositorLine): string {
if (typeof line === "string") {
- return line.split(/\r?\n/u).map(renderTelegramProgressStringLine).filter(Boolean).join("
");
+ 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 = [`${escapeTelegramProgressHtml(label)}`];
@@ -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()
- ? [`${escapeTelegramProgressHtml(heading.trim())}`, ...renderedLines]
- : renderedLines;
- const html = htmlParts.join("
");
- 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
+ ? [`${escapeTelegramProgressHtml(label)}`, ...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",
});
diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts
index 251b82b39b80..edb920b646d4 100644
--- a/extensions/telegram/src/draft-stream.test.ts
+++ b/extensions/telegram/src/draft-stream.test.ts
@@ -573,13 +573,13 @@ describe("createTelegramDraftStream", () => {
stream.updatePreview({
text: "Shelling\n\n`š ļø Exec`",
richMessage: {
- html: "Shelling
š ļø Exec",
+ html: "Shelling\nš ļø Exec",
skip_entity_detection: true,
},
});
await stream.flush();
- expect(api.sendMessage).toHaveBeenCalledWith(123, "Shelling
š ļø Exec", {
+ expect(api.sendMessage).toHaveBeenCalledWith(123, "Shelling\nš ļø Exec", {
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: "Shelling
š ļø Exec
Checking files",
+ html: "Shelling\nš ļø Exec\nChecking files",
skip_entity_detection: true,
},
});
@@ -596,12 +596,70 @@ describe("createTelegramDraftStream", () => {
expect(api.editMessageText).toHaveBeenCalledWith(
123,
17,
- "Shelling
š ļø Exec
Checking files",
+ "Shelling\nš ļø Exec\nChecking files",
{ 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: "Shelling
š ļø Exec",
+ skip_entity_detection: true,
+ },
+ });
+ await stream.flush();
+
+ expect(api.sendMessage).toHaveBeenCalledWith(123, "Shelling\nš ļø Exec", {
+ parse_mode: "HTML",
+ });
+ expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
+
+ stream.updatePreview({
+ text: "Shelling\n\nš ļø Exec\n⢠Checking files",
+ richMessage: {
+ html: "Shelling
š ļø Exec
Update Checking files",
+ skip_entity_detection: true,
+ },
+ });
+ await stream.flush();
+
+ expect(api.editMessageText).toHaveBeenCalledWith(
+ 123,
+ 17,
+ "Shelling\nš ļø Exec\nUpdate Checking files",
+ { 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: "Shelling\nš ļø Exec",
+ skip_entity_detection: true,
+ },
+ });
+ await stream.flush();
+
+ expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Shelling\nš ļø Exec", {
+ 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,
- minInitialChars = 30,
- minInitialDelayMs?: number,
- ) {
+ function createDebouncedStream(api: ReturnType, 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);
diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts
index 6e24e7322b40..578e4171b708 100644
--- a/extensions/telegram/src/draft-stream.ts
+++ b/extensions/telegram/src/draft-stream.ts
@@ -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(/
/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 | 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();
};
diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts
index ca4cc80fafde..e51728bfeb78 100644
--- a/src/agents/tool-display-config.ts
+++ b/src/agents/tool-display-config.ts
@@ -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",
diff --git a/src/channels/streaming.ts b/src/channels/streaming.ts
index 179441805121..a2c68ddcd03e 100644
--- a/src/channels/streaming.ts
+++ b/src/channels/streaming.ts
@@ -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: