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: