Skip to content

feat(channels): dynamic filler messages during long agent turns (#600)#636

Merged
senamakel merged 5 commits into
tinyhumansai:mainfrom
oxoxDev:feat/channels-filler-messages-600
Apr 17, 2026
Merged

feat(channels): dynamic filler messages during long agent turns (#600)#636
senamakel merged 5 commits into
tinyhumansai:mainfrom
oxoxDev:feat/channels-filler-messages-600

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented Apr 17, 2026

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:

  1. Silent long turns — while the agent is "thinking" for extended periods, the only visible UI was a single "💭 snippet" message being edited in-place. Users couldn't tell whether activity was ongoing or stalled.
  2. Edit-loop risk — when Telegram returned a response shape our extract_message_id couldn't parse (or dropped the id), the edit timer kept re-arming and retrying edits against a non-existent message id, burning rate budget.
  3. Orphan drafts on finalize — if the final reply edit failed mid-send, the draft bubble could linger while the fresh reply raced the cleanup path.

Solution

Five scoped commits on top of upstream/main:

  1. refactor(channels) — Extract extract_message_id helper handling str/i64/u64 candidates across the 4 response shapes Telegram returns.
  2. fix(channels) — Add thinking_edit_disabled latch on StreamingState; set it in both failure paths of flush_thinking_message and guard the edit-timer arm so a missing id can't start an edit loop.
  3. 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.
  4. fix(channels) — Rewrite finalize_channel_reply with 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.
  5. feat(channels) — Ephemeral filler pipeline: FILLER_INTERVAL = 13s, MAX_FILLER_FAILURES = 2, MAX_FILLER_CHARS = 200, 3-item static filler pool. Adds filler_message_ids, filler_index, filler_failures, filler_disabled, last_filler_snippet to StreamingState; adds a filler timer branch to the tokio::select! loop; extends finalize cleanup to delete fillers before the thinking bubble.

All new timers use MissedTickBehavior::Delay with the consumed-first-tick pattern so they don't fire immediately on state entry.

Submission Checklist

  • Tested locally against a running Telegram channel — verified filler cadence, thinking label, orphan cleanup, and edit-latch behavior
  • cargo check clean (ran against full workspace)
  • Changes scoped to src/openhuman/channels/bus.rs (+257/-71 vs upstream/main)
  • No new panics, no new unwraps on external input
  • Debug logs added at checkpoints (filler send/fail, edit latch trip, finalize path selection)
  • Micro-commits (5), each independently reviewable and revertible
  • Commits GPG-signed (key EFF0FAE29CE06407)
  • Added/updated unit tests — N/A: behavior is time-driven against Telegram's live API; covered manually via live-channel validation
  • Added/updated E2E tests — N/A: Telegram channel path not in the E2E harness

Impact

  • User-visible: clearer thinking bubble copy; ephemeral filler pings during long turns so users know the agent is still working; zero orphan drafts on finalize.
  • Reliability: no more runaway edit loops when Telegram response parsing yields no id; filler disables itself after MAX_FILLER_FAILURES consecutive sends fail.
  • Scope: single file, src/openhuman/channels/bus.rs. No schema, RPC, or frontend changes.

Related

Summary by CodeRabbit

Release Notes

  • New Features

    • Added periodic filler messages to improve message delivery reliability.
    • Thinking messages now display with a "💭 Thinking:" indicator.
  • Bug Fixes

    • Improved handling of thinking messages when message IDs are unavailable.
    • Fixed message delivery flow to prevent orphaned draft messages.

oxoxDev and others added 5 commits April 18, 2026 00:38
… 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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

Modified 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

Cohort / File(s) Summary
Filler Messages Mechanism
src/openhuman/channels/bus.rs
Added FILLER_INTERVAL constant, new StreamingState fields (filler_message_ids, filler_index, filler_failures, filler_disabled, last_filler_snippet), and tokio::select! branch to periodically send filler messages when enabled. Manages failure caps and disables filler on repeated failures.
Thinking Message Handling
src/openhuman/channels/bus.rs
Introduced thinking_edit_disabled latch in StreamingState to prevent further thinking updates when initial message can't be made editable. Changed format to 💭 Thinking:\n... prefix. Missing-id cases now log warnings and disable subsequent updates.
Message-ID Extraction & Cleanup
src/openhuman/channels/bus.rs
Centralized backend message-id extraction into shared extract_message_id() helper supporting multiple response shapes and numeric IDs. Updated draft-thinking sending and finalize logic to use the helper.
Finalize Reply Control Flow
src/openhuman/channels/bus.rs
Restructured finalize_channel_reply to deliver canonical reply before cleanup, delete all tracked filler messages first, then delete thinking message. Failed draft edits now delete orphan draft and send fresh atomic reply.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit hops through message streams, 🐰
With thinking bubbles and filler dreams,
When final replies must take the stage,
Cleanup first—a savvy engage,
Now Telegram chats stay neat and clean! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main change: adding dynamic filler messages during agent processing in Telegram channels.
Linked Issues check ✅ Passed PR fulfills all coding requirements from #600: tracks and deletes thinking/filler message IDs, prevents edit loops, handles deletion gracefully, and ensures clean final response delivery.
Out of Scope Changes check ✅ Passed All changes in src/openhuman/channels/bus.rs are directly scoped to addressing #600's objectives without introducing unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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_timer fires on a fixed 13 s cadence regardless of other activity. During an active text_delta stream (where flush_streaming_edit is 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 a text_delta/tool_result event 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_snippet is recorded before the send completes.

On line 628 the snippet is committed to last_filler_snippet before 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5082f3e and a69cdd6.

📒 Files selected for processing (1)
  • src/openhuman/channels/bus.rs

Comment on lines 540 to +542
snippet.push('…');
}
let text = format!("💭 _{snippet}_");
let text = format!("💭 Thinking:\n_{snippet}_");
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.

⚠️ Potential issue | 🟡 Minor

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

@senamakel senamakel merged commit c748732 into tinyhumansai:main Apr 17, 2026
8 checks passed
AusAgentSmith pushed a commit to AusAgentSmith/openhuman that referenced this pull request May 23, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Agent thinking messages persist in Telegram chat after final response

2 participants