Skip to content

Telegram: stream live status updates during agent turns - #3576

Merged
gcsecsey merged 11 commits into
trunkfrom
gcsecsey/stu-1688-telegram-streaming
May 22, 2026
Merged

Telegram: stream live status updates during agent turns#3576
gcsecsey merged 11 commits into
trunkfrom
gcsecsey/stu-1688-telegram-streaming

Conversation

@gcsecsey

@gcsecsey gcsecsey commented May 21, 2026

Copy link
Copy Markdown
Member

Related issues

  • Related to STU-1688
  • Companion PR 219222-ghe-Automattic/wpcom

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 action field.

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 progress and info NDJSON 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 ProgressStreamer to:

  1. Edit a single live status message in place instead of posting new ones. First qualifying event creates the message, subsequent events edit it, and the cadence floor drops from 10s to 3s to match Telegram's separate edit-message rate-limit bucket (~20 edits/min/chat).
  2. Surface the LLM's own narration alongside tool-internal progress: message_end text blocks from pi-coding-agent and thinking blocks fall through to the bare tool name when the model emits no preamble. Falls back gracefully when the LLM emits only a toolCall with no narration. Tool-internal progress / info events (already i18n'd by the tools that emit them, e.g. "Stopping WordPress server…", "Process daemon started") keep flowing through unchanged.
  3. Format every dynamic line as markdown italic (_..._), which the wpcom server converts to <i>...</i> via the existing markdown_to_telegram_html pipeline. Underscores inside site names like my_site pass through cleanly thanks to the server-side non-word-boundary regex.
  4. Replace the status with the actual reply when possible via 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 ✅ Done as 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 ⚠️ summary (e.g. ⚠️ Took too long, ⚠️ Hit the turn limit) so the user can see what happened.
  5. Drain in-flight photos before finalizing the status so a late-arriving media.share POST doesn't land after the status summary out of chat order.
  6. Skip no-op edits with a same-text guard so duplicate progress events from parallel tool runs (e.g. three sites stopping simultaneously all emit "Stopping WordPress server…") don't burn edit-bucket slots on Telegram-rejected message is not modified responses. 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:

  • RespondParams gains optional action ('create' | 'edit') and messageId, with up-front validation and clear errors. The wpcom endpoint also accepts delete and chat_action, but neither has a production caller in this PR — see point 4 above for the rationale behind not using delete.
  • respondMessage now returns Promise<RespondOutcome> carrying success, messageIds[], retryAfterMs?, and the existing text_sent / photo_sent / chunks_sent fields — existing callers ignore the return value and keep working
  • Telegram 429s are surfaced as retryAfterMs inside 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.log exposes the new behavior without needing debug-level logs.

Testing Instructions

  1. Make sure the wpcom companion 219222-ghe-Automattic/wpcom is checked out to your sandbox
  2. Pull this branch, run npm install, then npm run cli:build.
  3. Start the daemon: node apps/cli/dist/cli/main.mjs code remote-session start --no-detach
  4. From Telegram, send a multi-step request like: "stop all sites, then start only Site X, then take a screenshot".
  5. Check in the logs that the "Progress streamer post" messages appear for the updates
CleanShot 2026-05-21 at 16 09 41@2x 6. Confirm the live message edits in place through a sequence

Demo 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
telegram beeper
image image
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.
gcsecsey added 3 commits May 21, 2026 14:44
…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.
@gcsecsey
gcsecsey requested a review from epeicher May 21, 2026 15:27
@gcsecsey
gcsecsey marked this pull request as ready for review May 21, 2026 15:27
@epeicher

Copy link
Copy Markdown
Contributor

That looks amazing @gcsecsey! This greatly improves the UX in the Telegram side, I will ask a review:

@claude Can you please review this PR?

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @epeicher's task in 4m 15s —— View job


PR Review

  • Read PR context and description
  • Review telegram-client.ts changes
  • Review progress-streamer.ts changes
  • Review poll-loop.ts changes
  • Review test changes
  • Post review feedback

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 ProgressStreamer is dense. A few things I'd want to see addressed or at least acknowledged before merge:

Issues flagged inline

  1. Truncation can break the italic span (progress-streamer.ts:554-557). formatLine truncates after italic-wrapping, so long lines lose their closing _ and render literally on Telegram. The existing length test wouldn't catch it.
  2. Stale class-level docstring (progress-streamer.ts:191-198). The class summary still says success → DELETE; the implementation edits to ✅ _Done_. The PR's "Proposed Changes" bullet fix: Improve site creation error handling #4 has the same contradiction.
  3. Internal status codes leak to chat (progress-streamer.ts:371). Users see ⚠️ _spawn_error_ / ⚠️ _max_turns_ — agent-internal identifiers, not user copy. Same i18n concern for the English-only humanizeToolName verbs.
  4. lastPostedText set before knowing the post succeeded (progress-streamer.ts:415). A failed post still poisons the same-text dedupe cache, dropping the next identical event.
  5. Unused delete / chat_action actions (telegram-client.ts:204-230). Wired into the client + tests but never invoked by the streamer (which only uses create + edit). Trim to what this PR actually needs, or wait for the consumer to land.
  6. Stop-vs-active-post race (progress-streamer.ts:355-382). If waitForActivePost times out, the aborted POST may still land at Telegram after the new finalize edit, briefly reverting the visible message.

General observations

  • The cross-PR contract with wpcom is described well — the additive-action design means rollout order is forgiving, which is the right call.
  • The "no-op edit" guard, retry-after backoff, and the photo-drain-before-finalize ordering are all well thought through.
  • The PR is large (1265/-88) but the changes are tightly scoped to the remote-session daemon, which is appropriate for a single feature.

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.
• Branch: gcsecsey/stu-1688-telegram-streaming

Comment thread apps/cli/remote-session/progress-streamer.ts Outdated
Comment thread apps/cli/remote-session/progress-streamer.ts Outdated
Comment thread apps/cli/remote-session/progress-streamer.ts Outdated
Comment thread apps/cli/remote-session/progress-streamer.ts
Comment thread apps/cli/remote-session/telegram-client.ts Outdated
Comment thread apps/cli/remote-session/progress-streamer.ts
@gcsecsey

Copy link
Copy Markdown
Member Author

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. 👍

@wpmobilebot

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing 2ac1d7d vs trunk

app-size

Metric trunk 2ac1d7d Diff Change
App Size (Mac) 1339.06 MB 1339.06 MB +0.00 MB ⚪ 0.0%

site-editor

Metric trunk 2ac1d7d Diff Change
load 1782 ms 1730 ms 52 ms 🟢 -2.9%

site-startup

Metric trunk 2ac1d7d Diff Change
siteCreation 9652 ms 9611 ms 41 ms ⚪ 0.0%
siteStartup 4919 ms 4916 ms 3 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

@epeicher epeicher left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the comments @gcsecsey! Code changes LGTM!

@gcsecsey
gcsecsey merged commit a5408c9 into trunk May 22, 2026
23 checks passed
@gcsecsey
gcsecsey deleted the gcsecsey/stu-1688-telegram-streaming branch May 22, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants