Telegram: stream live status updates during agent turns - #3576
Conversation
Replaces the "post a new ⏳ message every 10 seconds" loop with a single live status message that edits in place over the course of a turn, deletes itself on success, and collapses to a⚠️ summary on failure. Surfaces tool calls, thinking, and message_end text deltas (not just the existing progress/info events) so the chat conveys the same level of detail Studio's desktop UI shows. Companion to a wpcom-side change that extends POST /local-agent-respond with action=edit/delete/chat_action and message_ids in the response envelope — the contract is additive so existing clients keep working.
…ol names
The previous iteration shipped a 28-entry TOOL_DESCRIBERS map plus a
per-toolCallId args cache, all to turn tool_execution_start /
tool_execution_end events into lines like "🔧 Stopping Catnap" /
"✅ Stopped Catnap". But the model already writes one-line narrations
as text blocks in message_end ("Stopping all running sites first!",
"All stopped! Now starting Niche Coffee.") — context-aware in a way no
static label could match, and emitted just before the tool runs.
Drops the descriptor map, args cache, callback plumbing, and the
tool_execution_* render branches. formatAssistantMessage now prefers
text > thinking > toolCall name, with the bare tool name only as a
fallback when the LLM emits no narration. Tool-internal progress events
(already i18n'd by their emitting tools) keep flowing through unchanged.
Also adds a same-text guard so duplicate progress events from parallel
tool runs (three "Stopping WordPress server…" lines in a row) don't
burn edit-bucket slots on Telegram-rejected no-op edits.
Net: -297 lines from progress-streamer.ts, and the streamer now picks
up i18n for free instead of bypassing it with hand-written English.
…are tool calls Replaces the "delete the status, then post the reply as a new message" flow with an in-place edit when the reply fits in one Telegram message. Two wins: 1. No more redundant "✅ Done" line above the actual reply for typical text-only turns — the live status simply becomes the reply. 2. Some Telegram clients (Beeper in particular) render a deleted message as a persistent "🗑 This message has been deleted" tombstone above the real reply. Editing the status into the reply sidesteps that. For media-only turns, multi-chunk replies, or error/timeout statuses the streamer falls back to the previous behavior: edit the status to a "✅ Done" / "⚠️ <status>" summary and post the reply (or photo) as a separate message. Also fixes the `share_screenshot` cosmetic: when the LLM emits a `message_end` with only a `toolCall` block (no text preamble), the fallback used to render the raw `share_screenshot` identifier. It now gerundizes via a small verb-to-participle table (~20 verbs) — so `share_screenshot` → "Sharing screenshot…" and `site_stop` → "Stopping site…" — keeping the same `⏳ _italic…_` visual style as `progress` envelopes. Far smaller than the per-tool descriptor map this PR already deleted, because it covers the snake_case PATTERN rather than specific phrasing per tool. Adds `ProgressStreamer.replaceWithReply(text)` for the absorb path, re-routes `stop('success')` from `action: 'delete'` back to an edit that lands `✅ _Done_`, and threads `replyForAbsorb` / `absorbedReply` through `handleTurn` so the post-finally branches skip `postChunks` when the reply has already been absorbed.
|
Claude finished @epeicher's task in 4m 15s —— View job PR Review
Nice piece of work — the design (single live message, edit cadence floor, retry-after backoff, drain-before-finalize) is solid and the test coverage on Issues flagged inline
General observations
Nothing in here is a blocker — items 1, 2, and 5 are the highest-value fixes; the others are worth a comment or follow-up. |
|
Thanks @epeicher for checking this and for enqueueing the claude review, it has some great points, that for some reason my local review agent didn't raise. I'll look into and fix these, and then ping you for another review. 👍 |
📊 Performance Test ResultsComparing 2ac1d7d vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
Related issues
The companion PR needs to be deployed before this change. The contract is additive, before the server PR is deployed, the CLI silently falls back to the existing "post a new message every 10 seconds" behavior because the server ignores the unknown
actionfield.How AI was used in this PR
Claude implemented the changes following my spec, then helped me iterate the approach. I tested each iteration manually.
Proposed Changes
Today the Telegram remote-session daemon only forwards
progressandinfoNDJSON events to chat, and it does so by posting a brand-new message for each update — leaving a noisy trail of "⏳ Loading site..." lines that don't reflect what the agent is actually doing. Tool calls, thinking, and assistant text deltas (the things that make the desktop UI feel alive) never reach Telegram at all.This PR rewires the daemon's
ProgressStreamerto:message_endtextblocks frompi-coding-agentandthinkingblocks fall through to the bare tool name when the model emits no preamble. Falls back gracefully when the LLM emits only atoolCallwith no narration. Tool-internalprogress/infoevents (already i18n'd by the tools that emit them, e.g. "Stopping WordPress server…", "Process daemon started") keep flowing through unchanged._..._), which the wpcom server converts to<i>...</i>via the existingmarkdown_to_telegram_htmlpipeline. Underscores inside site names likemy_sitepass through cleanly thanks to the server-side non-word-boundary regex.replaceWithReply()so the turn occupies a single message in chat. When the reply can't be folded in (photo reply, oversized text), the status is edited to✅ Doneas a clear visual close — translated, and not deleted, because some Telegram clients (Beeper) render deletions as a persistent "🗑 This message has been deleted" tombstone. Terminal failures edit the status to a translated⚠️ Took too long,⚠️ Hit the turn limit) so the user can see what happened.media.sharePOST doesn't land after the status summary out of chat order.message is not modifiedresponses. The dedupe cache only primes after Telegram accepts the post, so a transient network failure doesn't silently drop the next identical event.The wire-side changes in
telegram-client.ts:RespondParamsgains optionalaction('create' | 'edit') andmessageId, with up-front validation and clear errors. The wpcom endpoint also acceptsdeleteandchat_action, but neither has a production caller in this PR — see point 4 above for the rationale behind not usingdelete.respondMessagenow returnsPromise<RespondOutcome>carryingsuccess,messageIds[],retryAfterMs?, and the existingtext_sent/photo_sent/chunks_sentfields — existing callers ignore the return value and keep workingretryAfterMsinside a 200 envelope rather than as a transient error, so the streamer can back off precisely without inheriting generic retry-on-5xx semantics.The streamer adds an INFO-level "Progress streamer post" log line on every create/edit so
tail -f ~/.studio/remote-session.logexposes the new behavior without needing debug-level logs.Testing Instructions
npm install, thennpm run cli:build.node apps/cli/dist/cli/main.mjs code remote-session start --no-detachDemo video
CleanShot.2026-05-21.at.16.18.30.mp4
Note
An earlier iteration deleted the status message on success instead of editing it to
✅ Done. On Beeper and other 3rd party clients that shows up as a persistent "🗑 This message has been deleted" tombstone, so I switched to the edit approach.Here's how the delete variant looked side-by-side:
CleanShot.2026-05-21.at.16.13.11.mp4