feat(channels): dynamic filler messages during long agent turns (#600)#636
Conversation
… shapes
Backend send_channel_message responses use at least three shapes:
{"id":"..."}, {"data":{"id":"..."}}, and {"messageId":1456,"success":true}.
The last one returns the id as a JSON number, so the prior inline
as_str()-only extraction silently dropped it. Consolidate the extraction
into a single helper that handles str/i64/u64 candidates and routes both
streaming-edit and thinking-message send paths through it. Fixes the
silent id loss that left thinking bubbles undeletable (tinyhumansai#600).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the initial thinking POST returned 200 but carried no message id (either because the response shape was unexpected or the send itself failed before C1), every subsequent thinking_dirty tick re-entered the "send new message" branch. The user then saw one standalone italic bubble per accumulated snippet instead of a single evolving one. Add a thinking_edit_disabled latch that is set in both failure paths and guard the edit_timer branch on it so we post at most one thinking bubble per turn when the id is unrecoverable (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A bare italicized snippet under a 💭 emoji reads ambiguously in chat — it could be a user quote, a system note, or assistant output. Prefix the italic body with an explicit "Thinking:" line so the ephemeral bubble is unmistakably the LLM's reasoning stream and visually distinct from both filler messages and the final reply (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two problems in the old finalize path. (1) The thinking bubble was deleted before the reply was sent, leaving the chat momentarily empty between the two round-trips. (2) When the final edit failed, the half- streamed draft was left in place and the user never received the canonical response — a silent data-loss hole during edit-endpoint flakiness. Wrap the three delivery paths in a 'send: labeled block so they share a single cleanup tail, delete the orphan draft and send a fresh atomic reply on edit failure, and move the thinking-bubble deletion to after the reply is on screen so the chat never blinks empty (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…pets
Long agent turns (30–90 s) leave the chat static once the thinking
stream goes quiet and progressive edits stop, which reads as a frozen
bot. Add a third timer branch in the inbound loop that posts a short
"still working" message every FILLER_INTERVAL (13 s, tuned to stay
inside Telegram's ~1 msg/sec chat cap with headroom).
Each filler prefers a tail slice of the live thinking_accumulator
(last MAX_FILLER_CHARS = 200 Unicode scalars, trimmed at a word
boundary so it reads cleanly) so the user sees the agent's actual
reasoning instead of canned text. When the accumulator hasn't advanced
since the last filler we fall through to a rotating STATIC_FILLERS
pool ("💭 Still working on it…", etc.) so the chat still moves. All
filler ids are tracked in StreamingState.filler_message_ids and
deleted in finalize_channel_reply alongside the thinking bubble, so
the user ends each turn seeing only the canonical reply. A
filler_disabled latch stops hammering endpoints that reject filler
sends twice in a row.
Verified end-to-end on Telegram with both long (74 s, 5 fillers) and
short (32 s, 2 fillers) turns — all ephemeral messages cleaned up
cleanly after the final reply landed (tinyhumansai#600).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
📝 WalkthroughWalkthroughModified the Telegram channel bus event loop to implement filler messages, improve thinking message handling with edit-disable latch behavior, centralize message-id extraction, and restructure finalize reply delivery to ensure thinking messages are deleted before final response dispatch. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/openhuman/channels/bus.rs (2)
119-121: Consider pausing filler dispatch while the draft is actively streaming.
filler_timerfires on a fixed 13 s cadence regardless of other activity. During an activetext_deltastream (whereflush_streaming_editis already updating a visible draft every second), this will interleave💭 _…_bubbles underneath a draft that is itself moving — noisy UX and extra backend traffic. A low-cost improvement is to reset the timer (or skip a tick) when atext_delta/tool_resultevent has arrived within the last interval.♻️ Sketch
// In StreamingState: last_activity_at: Option<tokio::time::Instant>, // On text_delta / tool_call / tool_result: streaming_state.last_activity_at = Some(tokio::time::Instant::now()); // In the filler tick arm: let recently_active = streaming_state .last_activity_at .is_some_and(|t| t.elapsed() < FILLER_INTERVAL); if !streaming_state.filler_disabled && !recently_active { send_filler_message(channel, &mut streaming_state).await; }Also applies to: 224-228
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/openhuman/channels/bus.rs` around lines 119 - 121, The filler timer currently fires every FILLER_INTERVAL regardless of active streaming, causing filler bubbles to interleave with flush_streaming_edit updates; add activity tracking to StreamingState (e.g., last_activity_at: Option<tokio::time::Instant>), update it when handling text_delta / tool_call / tool_result events, and change the filler tick branch that uses filler_timer to check that last_activity_at is either None or older than FILLER_INTERVAL before calling send_filler_message (or reset/skip the timer tick when recent activity is detected) so fillers are suppressed during active streaming.
625-637:last_filler_snippetis recorded before the send completes.On line 628 the snippet is committed to
last_filler_snippetbefore the POST on line 643 is attempted. If the send fails, the snippet is marked as "already surfaced" even though the user never saw it — subsequent ticks with the same (or similar) accumulator will fall through to the static pool instead of retrying. Not a correctness bug (the static fallback keeps the chat moving), but worth moving the assignment into the success arm for intent clarity.♻️ Proposed tweak
- let text = match latest_thinking_snippet(state) { - Some(snippet) if state.last_filler_snippet.as_deref() != Some(snippet.as_str()) => { - state.last_filler_snippet = Some(snippet.clone()); - format!("💭 _{snippet}…_") - } - _ => { + let (text, pending_snippet) = match latest_thinking_snippet(state) { + Some(snippet) if state.last_filler_snippet.as_deref() != Some(snippet.as_str()) => { + let body = format!("💭 _{snippet}…_"); + (body, Some(snippet)) + } + _ => { let pool = STATIC_FILLERS; let idx = state.filler_index % pool.len(); state.filler_index = state.filler_index.wrapping_add(1); - pool[idx].to_string() + (pool[idx].to_string(), None) } }; // … match client.send_channel_message(channel, &jwt, body).await { Ok(resp) => { state.filler_failures = 0; + if let Some(s) = pending_snippet { + state.last_filler_snippet = Some(s); + } // …🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/openhuman/channels/bus.rs` around lines 625 - 637, In send_filler_message, don't set state.last_filler_snippet before attempting the POST; instead, build the text using latest_thinking_snippet (and keep snippet in scope), attempt the send, and only on successful send update state.last_filler_snippet = Some(snippet.clone()); leave the static-pool branch and filler_index update unchanged so failures will not mark the snippet as already-surfaced and will allow retry on the next tick.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/openhuman/channels/bus.rs`:
- Around line 540-542: The snippet is interpolated into text = format!("💭
Thinking:\n_{snippet}_") and sent via send_channel_message / send_channel_edit
to Telegram which uses Markdown parse_mode, causing underscores, backticks,
asterisks or brackets in snippet to break or reject messages; fix by escaping
Markdown-special characters (backtick `, underscore _, asterisk *, left bracket
[) in snippet before formatting, or remove the surrounding italics; update the
code paths that build the bubble (the variable snippet and the format! call
producing text) to call an escape helper (e.g., escape_for_telegram_markdown) or
strip italics so messages sent from send_channel_message/send_channel_edit are
safe, and ensure the Telegram provider (channel.rs) still receives plain text
without unescaped Markdown entities.
---
Nitpick comments:
In `@src/openhuman/channels/bus.rs`:
- Around line 119-121: The filler timer currently fires every FILLER_INTERVAL
regardless of active streaming, causing filler bubbles to interleave with
flush_streaming_edit updates; add activity tracking to StreamingState (e.g.,
last_activity_at: Option<tokio::time::Instant>), update it when handling
text_delta / tool_call / tool_result events, and change the filler tick branch
that uses filler_timer to check that last_activity_at is either None or older
than FILLER_INTERVAL before calling send_filler_message (or reset/skip the timer
tick when recent activity is detected) so fillers are suppressed during active
streaming.
- Around line 625-637: In send_filler_message, don't set
state.last_filler_snippet before attempting the POST; instead, build the text
using latest_thinking_snippet (and keep snippet in scope), attempt the send, and
only on successful send update state.last_filler_snippet =
Some(snippet.clone()); leave the static-pool branch and filler_index update
unchanged so failures will not mark the snippet as already-surfaced and will
allow retry on the next tick.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fdfa3e1e-e33a-4e6c-b719-bf2c2ee0c982
📒 Files selected for processing (1)
src/openhuman/channels/bus.rs
| snippet.push('…'); | ||
| } | ||
| let text = format!("💭 _{snippet}_"); | ||
| let text = format!("💭 Thinking:\n_{snippet}_"); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Is there any parse_mode handling tied to send_channel_message / send_channel_edit?
rg -nP -C3 'parse_mode|MarkdownV2|send_channel_(message|edit|delete|typing)'Repository: tinyhumansai/openhuman
Length of output: 14853
Markdown injection risk — unescaped snippet content breaks italics in Telegram Markdown mode.
The reasoning text is wrapped in _…_ italics (lines 540-542, 629) and passed via send_channel_message/send_channel_edit to the backend, which applies "parse_mode": "Markdown" when relaying to Telegram (confirmed in src/openhuman/channels/providers/telegram/channel.rs lines 935, 1696).
LLM reasoning frequently contains underscores (foo_bar), backticks, asterisks, or square brackets — common in identifiers, code, citations, and URLs. In Markdown mode, an unescaped _ inside the snippet will prematurely terminate italics (breaking the bubble visually) or cause Telegram to reject the message with "can't parse entities".
Escape special characters in the snippet before interpolation for Telegram Markdown mode: `, _, *, [ should be escaped or omitted. Alternatively, remove the italic wrapping and rely on the 💭 prefix alone.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/openhuman/channels/bus.rs` around lines 540 - 542, The snippet is
interpolated into text = format!("💭 Thinking:\n_{snippet}_") and sent via
send_channel_message / send_channel_edit to Telegram which uses Markdown
parse_mode, causing underscores, backticks, asterisks or brackets in snippet to
break or reject messages; fix by escaping Markdown-special characters (backtick
`, underscore _, asterisk *, left bracket [) in snippet before formatting, or
remove the surrounding italics; update the code paths that build the bubble (the
variable snippet and the format! call producing text) to call an escape helper
(e.g., escape_for_telegram_markdown) or strip italics so messages sent from
send_channel_message/send_channel_edit are safe, and ensure the Telegram
provider (channel.rs) still receives plain text without unescaped Markdown
entities.
…humansai#600) (tinyhumansai#636) * refactor(channels): add extract_message_id helper for varied response shapes Backend send_channel_message responses use at least three shapes: {"id":"..."}, {"data":{"id":"..."}}, and {"messageId":1456,"success":true}. The last one returns the id as a JSON number, so the prior inline as_str()-only extraction silently dropped it. Consolidate the extraction into a single helper that handles str/i64/u64 candidates and routes both streaming-edit and thinking-message send paths through it. Fixes the silent id loss that left thinking bubbles undeletable (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(channels): latch thinking edits off when first POST yields no id When the initial thinking POST returned 200 but carried no message id (either because the response shape was unexpected or the send itself failed before C1), every subsequent thinking_dirty tick re-entered the "send new message" branch. The user then saw one standalone italic bubble per accumulated snippet instead of a single evolving one. Add a thinking_edit_disabled latch that is set in both failure paths and guard the edit_timer branch on it so we post at most one thinking bubble per turn when the id is unrecoverable (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style(channels): label thinking bubble with explicit "Thinking:" header A bare italicized snippet under a 💭 emoji reads ambiguously in chat — it could be a user quote, a system note, or assistant output. Prefix the italic body with an explicit "Thinking:" line so the ephemeral bubble is unmistakably the LLM's reasoning stream and visually distinct from both filler messages and the final reply (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(channels): reply-first finalize with orphan draft recovery Two problems in the old finalize path. (1) The thinking bubble was deleted before the reply was sent, leaving the chat momentarily empty between the two round-trips. (2) When the final edit failed, the half- streamed draft was left in place and the user never received the canonical response — a silent data-loss hole during edit-endpoint flakiness. Wrap the three delivery paths in a 'send: labeled block so they share a single cleanup tail, delete the orphan draft and send a fresh atomic reply on edit failure, and move the thinking-bubble deletion to after the reply is on screen so the chat never blinks empty (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(channels): ephemeral filler messages every 13s with dynamic snippets Long agent turns (30–90 s) leave the chat static once the thinking stream goes quiet and progressive edits stop, which reads as a frozen bot. Add a third timer branch in the inbound loop that posts a short "still working" message every FILLER_INTERVAL (13 s, tuned to stay inside Telegram's ~1 msg/sec chat cap with headroom). Each filler prefers a tail slice of the live thinking_accumulator (last MAX_FILLER_CHARS = 200 Unicode scalars, trimmed at a word boundary so it reads cleanly) so the user sees the agent's actual reasoning instead of canned text. When the accumulator hasn't advanced since the last filler we fall through to a rotating STATIC_FILLERS pool ("💭 Still working on it…", etc.) so the chat still moves. All filler ids are tracked in StreamingState.filler_message_ids and deleted in finalize_channel_reply alongside the thinking bubble, so the user ends each turn seeing only the canonical reply. A filler_disabled latch stops hammering endpoints that reject filler sends twice in a row. Verified end-to-end on Telegram with both long (74 s, 5 fillers) and short (32 s, 2 fillers) turns — all ephemeral messages cleaned up cleanly after the final reply landed (tinyhumansai#600). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Follow-up iteration on the Telegram "💭 Thinking…" flow shipped in #612. Adds ephemeral filler messages (every 13s) with dynamic agent snippets, clearer thinking-bubble labeling, reply-first finalize with orphan-draft recovery, and a guard against infinite edit loops when the first thinking POST yields no message id.
Problem
After #612 landed, two rough edges remained:
extract_message_idcouldn't parse (or dropped the id), the edit timer kept re-arming and retrying edits against a non-existent message id, burning rate budget.Solution
Five scoped commits on top of
upstream/main:refactor(channels)— Extractextract_message_idhelper handlingstr/i64/u64candidates across the 4 response shapes Telegram returns.fix(channels)— Addthinking_edit_disabledlatch onStreamingState; set it in both failure paths offlush_thinking_messageand guard the edit-timer arm so a missing id can't start an edit loop.style(channels)— Label the thinking bubble with an explicit"💭 Thinking:\n_{snippet}_"header instead of a bare italic snippet — easier for users to parse at a glance.fix(channels)— Rewritefinalize_channel_replywith a'send:labeled block: on edit failure we delete the orphan draft before sending a fresh atomic reply, and thinking-bubble deletion is moved after reply delivery so the user never sees a gap.feat(channels)— Ephemeral filler pipeline:FILLER_INTERVAL = 13s,MAX_FILLER_FAILURES = 2,MAX_FILLER_CHARS = 200, 3-item static filler pool. Addsfiller_message_ids,filler_index,filler_failures,filler_disabled,last_filler_snippettoStreamingState; adds a filler timer branch to thetokio::select!loop; extends finalize cleanup to delete fillers before the thinking bubble.All new timers use
MissedTickBehavior::Delaywith the consumed-first-tick pattern so they don't fire immediately on state entry.Submission Checklist
cargo checkclean (ran against full workspace)src/openhuman/channels/bus.rs(+257/-71 vsupstream/main)EFF0FAE29CE06407)Impact
MAX_FILLER_FAILURESconsecutive sends fail.src/openhuman/channels/bus.rs. No schema, RPC, or frontend changes.Related
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes