//! E2E tests for Telegram message routing through the real agent + message tool. #[cfg(feature = "libsql")] mod support; #[cfg(feature = "libsql")] mod tests { use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use futures::StreamExt; use ironclaw::agent::{Agent, AgentDeps}; use ironclaw::app::{AppBuilder, AppBuilderFlags}; use ironclaw::channels::web::log_layer::LogBroadcaster; use ironclaw::channels::{ Channel, ChannelManager, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate, }; use ironclaw::config::Config; use ironclaw::db::{Database, libsql::LibSqlBackend}; use ironclaw::error::ChannelError; use ironclaw::llm::{LlmProvider, SessionConfig, SessionManager}; use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::ReceiverStream; use crate::support::test_channel::{TestChannel, TestChannelHandle}; use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep, TraceToolCall}; type TelegramCaptures = Arc>>; struct RecordingTelegramChannel { captures: TelegramCaptures, } impl RecordingTelegramChannel { fn new() -> (Self, TelegramCaptures) { let captures = Arc::new(Mutex::new(Vec::new())); ( Self { captures: Arc::clone(&captures), }, captures, ) } } #[async_trait] impl Channel for RecordingTelegramChannel { fn name(&self) -> &str { "telegram" } async fn start(&self) -> Result { let (_tx, rx) = mpsc::channel::(1); Ok(ReceiverStream::new(rx).boxed()) } async fn respond( &self, _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { self.captures .lock() .await .push(("respond".to_string(), response)); Ok(()) } async fn send_status( &self, _status: StatusUpdate, _metadata: &serde_json::Value, ) -> Result<(), ChannelError> { Ok(()) } async fn broadcast( &self, user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { self.captures .lock() .await .push((user_id.to_string(), response)); Ok(()) } async fn health_check(&self) -> Result<(), ChannelError> { Ok(()) } } struct Harness { gateway: Arc, telegram_captures: Arc>>, db: Arc, owner_id: String, _temp_dir: tempfile::TempDir, agent_handle: Option>, } impl Harness { async fn store_telegram_owner_binding(&self, owner_id: i64) { for scope in [&self.owner_id, "test-user"] { self.db .set_setting( scope, "channels.wasm_channel_owner_ids.telegram", &serde_json::json!(owner_id), ) .await .expect("failed to store telegram owner binding"); } } async fn wait_for_telegram_broadcasts( &self, expected: usize, timeout: Duration, ) -> Vec<(String, OutgoingResponse)> { let deadline = tokio::time::Instant::now() + timeout; loop { let snapshot = self.telegram_captures.lock().await.clone(); if snapshot.len() >= expected || tokio::time::Instant::now() >= deadline { return snapshot; } tokio::time::sleep(Duration::from_millis(50)).await; } } } impl Drop for Harness { fn drop(&mut self) { self.gateway.signal_shutdown(); if let Some(handle) = self.agent_handle.take() { handle.abort(); } } } async fn build_harness(trace: LlmTrace) -> Harness { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let db_path = temp_dir.path().join("telegram_message_routing.db"); let backend = LibSqlBackend::new_local(&db_path) .await .expect("failed to create test LibSqlBackend"); backend .run_migrations() .await .expect("failed to run migrations"); let db: Arc = Arc::new(backend); let skills_dir = temp_dir.path().join("skills"); let installed_skills_dir = temp_dir.path().join("installed_skills"); let _ = std::fs::create_dir_all(&skills_dir); let _ = std::fs::create_dir_all(&installed_skills_dir); let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir); config.agent.auto_approve_tools = true; let session = Arc::new(SessionManager::new(SessionConfig::default())); let log_broadcaster = Arc::new(LogBroadcaster::new()); let llm: Arc = Arc::new(TraceLlm::from_trace(trace)); let mut builder = AppBuilder::new( config, AppBuilderFlags::default(), None, session, log_broadcaster, ); builder.with_database(Arc::clone(&db)); builder.with_llm(llm); let mut components = builder .build_all() .await .expect("AppBuilder::build_all() failed"); components.config.agent.auto_approve_tools = true; components.config.agent.allow_local_tools = true; let deps = AgentDeps { owner_id: components.config.owner_id.clone(), store: components.db.clone(), llm: components.llm.clone(), cheap_llm: components.cheap_llm.clone(), safety: components.safety.clone(), tools: components.tools.clone(), workspace: components.workspace.clone(), extension_manager: components.extension_manager.clone(), skill_registry: components.skill_registry.clone(), skill_catalog: components.skill_catalog.clone(), skills_config: components.config.skills.clone(), hooks: components.hooks.clone(), cost_guard: components.cost_guard.clone(), sse_tx: None, http_interceptor: None, transcription: None, document_extraction: None, sandbox_readiness: ironclaw::agent::routine_engine::SandboxReadiness::DisabledByConfig, builder: None, llm_backend: "nearai".to_string(), tenant_rates: std::sync::Arc::new(ironclaw::tenant::TenantRateRegistry::new(4, 3)), }; let gateway = Arc::new(TestChannel::new()); let gateway_handle = TestChannelHandle::new(Arc::clone(&gateway)); let (telegram_channel, telegram_captures) = RecordingTelegramChannel::new(); let channel_manager = ChannelManager::new(); channel_manager.add(Box::new(gateway_handle)).await; channel_manager.add(Box::new(telegram_channel)).await; let channels = Arc::new(channel_manager); deps.tools .register_message_tools(Arc::clone(&channels), deps.extension_manager.clone()) .await; let agent = Agent::new( components.config.agent.clone(), deps, channels, None, None, None, Some(Arc::clone(&components.context_manager)), None, ); let agent_handle = tokio::spawn(async move { if let Err(err) = agent.run().await { eprintln!("[telegram routing e2e] Agent exited with error: {err}"); } }); if let Some(rx) = gateway.take_ready_rx().await { let _ = tokio::time::timeout(Duration::from_secs(5), rx).await; } Harness { gateway, telegram_captures, db, owner_id: components.config.owner_id.clone(), _temp_dir: temp_dir, agent_handle: Some(agent_handle), } } fn single_message_trace(arguments: serde_json::Value, final_text: &str) -> LlmTrace { LlmTrace::single_turn( "telegram-message-routing", "send a reminder", vec![ TraceStep { request_hint: None, response: TraceResponse::ToolCalls { tool_calls: vec![TraceToolCall { id: "call_message_1".to_string(), name: "message".to_string(), arguments, }], input_tokens: 32, output_tokens: 12, }, expected_tool_results: Vec::new(), }, TraceStep { request_hint: None, response: TraceResponse::Text { content: final_text.to_string(), input_tokens: 24, output_tokens: 8, }, expected_tool_results: Vec::new(), }, ], ) } #[tokio::test] async fn telegram_message_tool_uses_bound_owner_target_when_target_omitted() { let harness = build_harness(single_message_trace( serde_json::json!({ "content": "Walk Conan", "channel": "telegram", }), "Sent on Telegram.", )) .await; harness.store_telegram_owner_binding(424242).await; harness .gateway .send_message("remind me to walk conan") .await; let responses = harness .gateway .wait_for_responses(1, Duration::from_secs(10)) .await; assert!( responses .iter() .any(|response| response.content.contains("Sent on Telegram")), "expected assistant confirmation, got: {:?}", responses .iter() .map(|response| &response.content) .collect::>() ); let broadcasts = harness .wait_for_telegram_broadcasts(1, Duration::from_secs(10)) .await; assert_eq!( broadcasts.len(), 1, "expected exactly one telegram broadcast" ); assert_eq!(broadcasts[0].0, "424242"); assert_eq!(broadcasts[0].1.content, "Walk Conan"); } #[tokio::test] async fn telegram_message_tool_prefers_explicit_target_over_bound_owner_target() { let harness = build_harness(single_message_trace( serde_json::json!({ "content": "Walk Conan", "channel": "telegram", "target": "999999", }), "Sent on Telegram.", )) .await; harness.store_telegram_owner_binding(424242).await; harness.gateway.send_message("send the reminder").await; let _ = harness .gateway .wait_for_responses(1, Duration::from_secs(10)) .await; let broadcasts = harness .wait_for_telegram_broadcasts(1, Duration::from_secs(10)) .await; assert_eq!( broadcasts.len(), 1, "expected exactly one telegram broadcast" ); assert_eq!(broadcasts[0].0, "999999"); assert_eq!(broadcasts[0].1.content, "Walk Conan"); } }