From a10509b1bd68db2b6b7d92eb3cff3499ef5c3b85 Mon Sep 17 00:00:00 2001 From: SergiioB Date: Thu, 23 Jul 2026 16:51:42 +0200 Subject: [PATCH] feat(telegram): add live progress indicator to remote-control channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post an editable "Thinking…" bubble the moment an owner message lands and update it live with the turn's activity (step count, tool name, step summary) until the reply is ready, then remove it so the answer stands alone. The existing chatAction "typing" dots are kept but are too subtle to read as an acknowledgement and vanish the instant a fast reply lands, leaving the operator with no visible feedback that their message was received. The indicator is pure channel infrastructure: every post/edit/delete is best-effort and serialised through an internal promise chain, throttled to ~1.4 edits/s with identical-text suppression, and a fast turn that removes the bubble before the initial send resolves still cleans up correctly. Adds an optional deleteMessage to the TelegramApi surface. --- src/channels/telegram/inbound-handler.ts | 165 ++++++++++++++++++++++- src/channels/telegram/outbound-sender.ts | 7 + 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/channels/telegram/inbound-handler.ts b/src/channels/telegram/inbound-handler.ts index 8b6fefc..490404f 100644 --- a/src/channels/telegram/inbound-handler.ts +++ b/src/channels/telegram/inbound-handler.ts @@ -218,16 +218,30 @@ async function dispatchToRuntime( let reply: string | null = null; let failure: { error: Error; category: LlmFailureCategory } | null = null; + // Live progress indicator: a single editable message that mirrors the + // turn's activity ("Thinking…" → "🔧 " → "✅ ") so the + // operator gets immediate, visible feedback that their message landed. + // The native `chatAction: "typing"` dots (kept running below) are too + // subtle and vanish the instant a fast reply lands. Torn down before the + // final reply is posted so the answer stands alone. Best-effort: a failed + // post/edit/delete never affects the turn outcome. + const progress = new TelegramProgressIndicator( + ctx.api, + chatId, + toTelegramLogger(ctx.logger), + ); const eventHook = (event: AgentLoopEvent): void => { if (event.type === "llm_event") { if (event.event.type === "assistant_reply") reply = event.event.text; - return; } if (event.type === "loop_failed") { failure = { error: event.error, category: event.category }; } + const label = progressLabel(event); + if (label !== null) progress.update(label); }; + progress.start("🤔 Thinking…"); const stopKeepalive = startTypingKeepalive(ctx, chatId); try { await ctx.runtime.runTurn(session, text, { @@ -247,6 +261,12 @@ async function dispatchToRuntime( } } + // Remove the progress indicator before posting the final text so the + // reply (or infra message) stands alone rather than appearing above a + // stale "Thinking…" bubble. Awaits the internal chain so a fast turn + // whose `start()` send is still in flight is cleaned up before we send. + await progress.remove(); + // Only the agent's natural-language reply gets parse-mode // formatting. Cancellation acks, the empty-reply marker, and // failure envelopes are channel infrastructure and stay plain so @@ -302,6 +322,149 @@ function startTypingKeepalive( return () => clearInterval(handle); } +/** + * Maximum length of a `step_finished.summary` echoed into the live progress + * bubble. Tool summaries can be long; clip so the indicator stays a glancable + * status line rather than a second reply. + */ +const PROGRESS_SUMMARY_MAX_CHARS = 80; + +/** + * Minimum gap between two progress edits. Telegram rate-limits + * `editMessageText` per chat; editing faster than this buys nothing visually + * and risks 429 flood-waits. Combined with the "skip identical text" guard in + * `update`, a chatty turn settles to at most ~1.4 edits/s. + */ +const PROGRESS_MIN_EDIT_INTERVAL_MS = 700; + +function truncateForProgress(text: string): string { + const trimmed = text.trim(); + if (trimmed.length <= PROGRESS_SUMMARY_MAX_CHARS) return trimmed; + return `${trimmed.slice(0, PROGRESS_SUMMARY_MAX_CHARS - 1)}…`; +} + +/** + * Map an `AgentLoopEvent` to a short, operator-facing progress label, or + * `null` when the event carries nothing worth surfacing (token accounting, + * streaming deltas, the terminal `assistant_reply` which the reply path + * already handles). Tool names are shown verbatim — they are stable, + * meaningful identifiers (`os.fs.write`, `browser.navigate`, …) and inventing + * friendlier verbs would drift from the tool registry. + */ +function progressLabel(event: AgentLoopEvent): string | null { + switch (event.type) { + case "turn_started": + return "🤔 Thinking…"; + case "step_started": + return `⚙️ Working… (step ${event.stepIndex + 1})`; + case "step_finished": { + const summary = event.summary.trim(); + return summary.length > 0 ? `✅ ${truncateForProgress(summary)}` : null; + } + case "loop_detected": + return `🔁 Repeating ${event.tool} (×${event.count})`; + case "llm_event": { + const step = event.event; + if (step.type === "tool_call_parsed") return `🔧 ${step.call.tool}`; + return null; + } + default: + return null; + } +} + +/** + * A single editable Telegram message that mirrors the current turn's + * activity, giving the operator immediate visible feedback that their + * message landed and is being worked on. Lifecycle: `start()` posts the + * bubble, `update()` edits it (throttled + deduped), `remove()` deletes it + * once the turn settles. Every network call is best-effort and serialised + * through an internal promise chain so a fast turn that calls `remove()` + * before `start()`'s `sendMessage` resolves still cleans up correctly (the + * send handler observes `removed` and deletes the just-posted message). + * Failures are logged (start) or swallowed (edit/delete) — the indicator is + * pure channel infrastructure and must never affect the turn outcome. + */ +class TelegramProgressIndicator { + private messageId: number | null = null; + private removed = false; + private lastText = ""; + private lastEditAt = 0; + private chain: Promise = Promise.resolve(); + + constructor( + private readonly api: TelegramApi, + private readonly chatId: number, + private readonly logger?: TelegramLogger, + private readonly minEditIntervalMs: number = PROGRESS_MIN_EDIT_INTERVAL_MS, + ) {} + + /** Post the initial indicator bubble. Fire-and-forget. */ + start(text: string): void { + this.lastText = text; + this.chain = this.chain.then(async () => { + if (this.removed) return; + try { + const sent = await this.api.sendMessage(this.chatId, text); + const messageId = + typeof sent === "object" && sent !== null && "message_id" in sent + ? (sent as { message_id?: number }).message_id + : undefined; + if (this.removed) { + // The turn finished while we were sending; don't leave a stale + // bubble behind. + if (typeof messageId === "number") { + await this.api.deleteMessage?.(this.chatId, messageId).catch( + () => undefined, + ); + } + return; + } + this.messageId = typeof messageId === "number" ? messageId : null; + this.lastEditAt = Date.now(); + } catch (err) { + this.logger?.warn("telegram: progress start failed (non-fatal)", { + chatId: this.chatId, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + } + + /** Throttled, deduped live update. Fire-and-forget. */ + update(text: string): void { + if (this.removed || text === this.lastText) return; + const now = Date.now(); + if (now - this.lastEditAt < this.minEditIntervalMs) return; + this.lastText = text; + this.lastEditAt = now; + this.chain = this.chain.then(async () => { + if (this.removed || this.messageId === null) return; + try { + await this.api.editMessageText?.(this.chatId, this.messageId, text); + } catch { + // "message is not modified", flood-wait, or already deleted — all + // non-fatal for a best-effort indicator. + } + }); + } + + /** Delete the indicator. Idempotent; resolves once the delete settles. */ + remove(): Promise { + this.removed = true; + this.chain = this.chain.then(async () => { + if (this.messageId === null) return; + try { + await this.api.deleteMessage?.(this.chatId, this.messageId); + } catch { + // Already gone or flood-wait — non-fatal. + } + this.messageId = null; + }); + return this.chain.then(() => undefined); + } +} + function formatStatus(ctx: InboundContext): string { const data = ctx.sessionPointer.read(); if (!data.current) { diff --git a/src/channels/telegram/outbound-sender.ts b/src/channels/telegram/outbound-sender.ts index e1ba4cb..1aeaff1 100644 --- a/src/channels/telegram/outbound-sender.ts +++ b/src/channels/telegram/outbound-sender.ts @@ -33,6 +33,13 @@ export interface TelegramApi { text: string, opts?: Record, ): Promise; + /** + * Delete a message by id. Optional because only the live-progress + * indicator (which posts then removes a single "Thinking…" bubble) + * needs it; inbound paths that never post transient messages can omit + * it. Best-effort at every call site — a failed delete is swallowed. + */ + deleteMessage?(chatId: number, messageId: number): Promise; /** * Acknowledge a `callback_query` so the in-app loading spinner on * the operator's client dismisses. Optional because the inbound