Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/openhuman/agent_registry/agents/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,13 @@ pub const BUILTINS: &[BuiltinAgent] = &[
id: "frontend_agent",
toml: include_str!("../../orchestration/frontend_agent/agent.toml"),
prompt_fn: crate::openhuman::orchestration::frontend_agent::prompt::build,
graph_fn: crate::openhuman::orchestration::frontend_agent::graph::graph,
graph_fn: Some(crate::openhuman::orchestration::frontend_agent::graph::graph),
},
BuiltinAgent {
id: "reasoning_agent",
toml: include_str!("../../orchestration/reasoning_agent/agent.toml"),
prompt_fn: crate::openhuman::orchestration::reasoning_agent::prompt::build,
graph_fn: crate::openhuman::orchestration::reasoning_agent::graph::graph,
graph_fn: Some(crate::openhuman::orchestration::reasoning_agent::graph::graph),
},
];

Expand Down
61 changes: 54 additions & 7 deletions src/openhuman/channels/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,16 @@ impl EventHandler for ChannelInboundSubscriber {
}
}
_ = edit_timer.tick() => {
if streaming_state.thinking_dirty && !streaming_state.thinking_edit_disabled {
flush_thinking_message(channel, &mut streaming_state).await;
}
if streaming_state.dirty && !streaming_state.edit_disabled {
flush_streaming_edit(channel, &mut streaming_state).await;
// Progressive draft/thinking bubbles require edit+delete
// support; skip them on channels that lack it (Discord) so
// they don't leave un-cleanable placeholder messages.
if channel_supports_progressive_ui(channel) {
if streaming_state.thinking_dirty && !streaming_state.thinking_edit_disabled {
flush_thinking_message(channel, &mut streaming_state).await;
}
if streaming_state.dirty && !streaming_state.edit_disabled {
flush_streaming_edit(channel, &mut streaming_state).await;
}
}
}
_ = typing_timer.tick() => {
Expand All @@ -258,7 +263,9 @@ impl EventHandler for ChannelInboundSubscriber {
}
}
_ = filler_timer.tick() => {
if !streaming_state.filler_disabled {
// Fillers ("💭 Still working on it…") are ephemeral and
// deleted on finalize — only post them where cleanup works.
if channel_supports_progressive_ui(channel) && !streaming_state.filler_disabled {
send_filler_message(channel, &mut streaming_state).await;
}
}
Expand Down Expand Up @@ -297,6 +304,28 @@ const MAX_TYPING_FAILURES: u32 = 2;
/// on finalization alongside the ephemeral thinking bubble.
const FILLER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_secs(13);

/// Whether a channel supports the progressive-UI placeholders — the
/// evolving draft bubble, the rotating "💭" fillers, and the ephemeral
/// "thinking" bubble. All three rely on the backend supporting **both**
/// message *edit* and *delete*: edit keeps a single bubble evolving in
/// place, delete removes it once the final reply lands. Telegram supports
/// both. Discord's adapter supports **neither** (edits 404, delete is a
/// hard `Delete not supported` stub), so every placeholder becomes a
/// permanent, un-editable, un-deletable message — the channel fills with
/// "💭 Still working on it…" bubbles.
///
/// This is an **allowlist**, not a denylist: only channels confirmed to
/// support edit+delete opt in. A new/unknown adapter therefore fails *safe*
/// (placeholders suppressed) rather than silently re-introducing the spam bug
/// this gate was added to fix.
fn channel_supports_progressive_ui(channel: &str) -> bool {
// Inbound channels arrive provider-prefixed from the socket layer
// (e.g. `discord:<guild>`, `tg:<chat>`), so compare the provider prefix,
// not the whole id — mirroring `channel_is_telegram`.
let provider = channel.split(':').next().unwrap_or(channel);
matches!(provider, "telegram" | "tg")
}

/// Maximum consecutive filler-send failures before we stop trying.
/// Same rationale as the thinking/typing latches.
const MAX_FILLER_FAILURES: u32 = 2;
Expand Down Expand Up @@ -1042,7 +1071,25 @@ fn channel_is_telegram(channel: &str) -> bool {

#[cfg(test)]
mod inbound_thread_id_tests {
use super::{derive_inbound_client_id, derive_inbound_thread_id};
use super::{
channel_supports_progressive_ui, derive_inbound_client_id, derive_inbound_thread_id,
};

#[test]
fn progressive_ui_is_an_allowlist_failing_safe_for_unknown_channels() {
// Only edit+delete-capable providers opt in. Telegram supports both;
// everything else (Discord's stub delete / 404 edits, and any new or
// unknown adapter) is suppressed so the "💭" spam can't reappear.
assert!(channel_supports_progressive_ui("telegram"));
assert!(channel_supports_progressive_ui("tg"));
// Inbound channels arrive provider-prefixed — the prefix must still match.
assert!(channel_supports_progressive_ui("tg:12345"));
assert!(!channel_supports_progressive_ui("discord"));
assert!(!channel_supports_progressive_ui("discord:guild-1"));
// Unknown/new adapters fail safe (allowlist, not denylist).
assert!(!channel_supports_progressive_ui("slack"));
assert!(!channel_supports_progressive_ui("whatsapp:123"));
}

#[test]
fn socket_inbound_client_id_keys_per_sender() {
Expand Down
25 changes: 24 additions & 1 deletion src/openhuman/channels/providers/telegram/channel_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use super::channel_types::{
TelegramChannel, TelegramUpdateWindow, TELEGRAM_RECENT_UPDATE_CACHE_SIZE,
};
use super::text::TELEGRAM_BIND_COMMAND;
use super::text::{TELEGRAM_BIND_COMMAND, TELEGRAM_START_COMMAND};
use crate::openhuman::config::{Config, StreamMode};
use crate::openhuman::security::pairing::PairingGuard;
use anyhow::Context;
Expand Down Expand Up @@ -123,6 +123,14 @@ impl TelegramChannel {
format!("{}/bot{}/{method}", self.api_base, self.bot_token)
}

/// Point outbound Telegram API calls at `base` (test-only seam). Used to
/// aim `send()` at a dead local port so onboarding tests exercise the
/// decision logic without reaching api.telegram.org.
#[cfg(test)]
pub(crate) fn set_api_base_for_tests(&mut self, base: impl Into<String>) {
self.api_base = base.into();
}

pub(crate) fn pairing_code_active(&self) -> bool {
self.pairing
.as_ref()
Expand All @@ -140,6 +148,21 @@ impl TelegramChannel {
parts.next().map(str::trim).filter(|code| !code.is_empty())
}

/// Whether `text` is the standard Telegram `/start` bot-onboarding command
/// (optionally addressed as `/start@botname`, with or without a payload).
///
/// On the self-bot-token path this is the operator's explicit "I'm setting up
/// my bot" signal: the first `/start` while pairing is still pending pairs the
/// sender (see `handle_unauthorized_message`), matching the "first sender after
/// /start" behaviour sanctioned by openhuman#4381.
pub(crate) fn is_start_command(text: &str) -> bool {
let Some(command) = text.split_whitespace().next() else {
return false;
};
let base_command = command.split('@').next().unwrap_or(command);
base_command == TELEGRAM_START_COMMAND
}

pub(crate) fn track_update_id(&self, update_id: i64) -> bool {
let mut window = self.recent_updates.lock();
if window.recent_lookup.contains(&update_id) {
Expand Down
Loading
Loading