Files
ironclaw/tests/e2e_telegram_message_routing.rs
Henry Park 2dc78b2d94 feat(db): add per-user CachedSettingsStore decorator (#2425)
* feat(db): add per-user CachedSettingsStore decorator

SettingsStore methods hit the database on every call. The v2 engine
path (effect_adapter) and the dispatcher's per-turn tool permission
loading both called get_all_settings() without caching, adding
unnecessary DB round-trips on every agentic loop iteration.

Add a write-through CachedSettingsStore decorator that caches
get_all_settings() results per user_id. Write operations (set_setting,
delete_setting, set_all_settings) delegate to the inner store then
invalidate that user's cache entry. The write lock is held across DB
loads to prevent stale-data races from concurrent invalidations.

Wire the cache into TenantScope via a new settings_store field on
AgentDeps, so all settings reads in the agent loop go through the
cache. Remove the per-turn cached_tool_permissions Mutex hack from
ChatDelegate that was working around the missing cache layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback

- Store Arc<HashMap> in cache instead of bare HashMap to avoid cloning
  the full settings map on every cache hit. get_setting/has_settings now
  only clone the single requested value or check emptiness through the Arc.
- Route get_setting_with_admin_fallback() through self.settings() instead
  of self.inner so both the per-user and admin lookups go through the cache.
- Update settings section comment to accurately describe which methods
  delegate through settings().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address all PR review feedback

- Use `crate::db::` imports instead of `super::` (convention fix)
- Add `wrap()` factory fn to CachedSettingsStore, simplify app.rs construction
- Store `Arc<HashMap>` in cache to avoid full map clones on hits
- Expose `invalidate_user()` and `flush()` public methods
- Wire `flush()` into SIGHUP handler via concrete `settings_cache` on AppComponents
- Wire `settings_store` into GatewayState and route all settings handlers
  through it so web UI writes invalidate the cache (critical fix)
- Route `get_setting_with_admin_fallback()` through `self.settings()`
- Add error-path test (FailingStore mock, cache not poisoned on error)
- Add concurrent-access test (8 concurrent readers, inner store hit once)
- Add TenantScope caller-level test (read/write through cache wiring)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reuse resolve_settings_store() in settings_tools_set_handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address all review feedback on CachedSettingsStore

- Add TTL (300s) and max-entries cap (1000) to bound cache staleness
  and memory growth. Entries expire after TTL; cache clears when cap
  exceeded.
- Route admin tool_policy GET/PUT through resolve_settings_store() so
  writes invalidate the __admin__ cache entry.
- Route settings_export_handler and settings_tools_list_handler through
  resolve_settings_store() (were bypassing cache on reads).
- Wire invalidate_user() into users_delete_handler and
  users_suspend_handler so deleted/suspended users' settings are evicted.
- Replace GatewayState.settings_store (trait object) with
  settings_cache (concrete CachedSettingsStore) — single field for both
  trait dispatch and cache management, no desync risk.
- Add settings_override to ExtensionManager with with_settings_store()
  builder. All settings reads/writes in ExtensionManager now route
  through the cached store when available.
- Make ExtensionManager::settings_store() pub(crate); update
  AuthManager to call it instead of database(), closing the auth
  descriptor cache bypass.
- Remove unused wrap() method; merge redundant invalidate/invalidate_user.
- Add tracing::debug on SIGHUP cache flush.
- Expand module docs with design assumptions, known bypass paths, TTL
  and eviction semantics.
- Add tests: expired_entry_triggers_reload, fresh_entry_does_not_reload,
  max_entries_cap_triggers_eviction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: collapse nested if into filter to satisfy clippy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:10:10 -07:00

360 lines
12 KiB
Rust

//! 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<Mutex<Vec<(String, OutgoingResponse)>>>;
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<MessageStream, ChannelError> {
let (_tx, rx) = mpsc::channel::<IncomingMessage>(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<TestChannel>,
telegram_captures: Arc<Mutex<Vec<(String, OutgoingResponse)>>>,
db: Arc<dyn Database>,
owner_id: String,
_temp_dir: tempfile::TempDir,
agent_handle: Option<tokio::task::JoinHandle<()>>,
}
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<dyn Database> = 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<dyn LlmProvider> = 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(),
settings_store: components.settings_store.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(),
auth_manager: None,
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::<Vec<_>>()
);
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");
}
}