//! Round22 raw coverage for high-miss tool and Composio branches. //! //! All outbound HTTP stays on loopback mocks. The tests drive public tool //! surfaces so coverage lands on the same code paths used by agent/tool calls. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use anyhow::Result; use async_trait::async_trait; use axum::body::to_bytes; use axum::extract::{Request, State}; use axum::http::{Method, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::any; use axum::{Json, Router}; use serde_json::{json, Value}; use tempfile::{Builder, TempDir}; use openhuman_core::openhuman::config::{Config, DelegateAgentConfig}; use openhuman_core::openhuman::cron::DeliveryConfig; use openhuman_core::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary}; use openhuman_core::openhuman::security::{AuditLogger, SecurityPolicy}; use openhuman_core::openhuman::tools::{ all_tools, ComposioTool, CronAddTool, TodoTool, Tool, ToolCallOptions, }; static ENV_LOCK: OnceLock> = OnceLock::new(); #[derive(Clone, Debug)] struct RecordedRequest { method: Method, path: String, query: String, api_key: Option, } #[derive(Clone, Default)] struct MockState { requests: Arc>>, } struct EnvGuard { key: &'static str, old: Option, } impl EnvGuard { fn set_path(key: &'static str, path: &Path) -> Self { let old = std::env::var(key).ok(); std::env::set_var(key, path.as_os_str()); Self { key, old } } fn unset(key: &'static str) -> Self { let old = std::env::var(key).ok(); std::env::remove_var(key); Self { key, old } } } impl Drop for EnvGuard { fn drop(&mut self) { match &self.old { Some(value) => std::env::set_var(self.key, value), None => std::env::remove_var(self.key), } } } struct Harness { _tmp: TempDir, workspace: PathBuf, config: Config, _guards: Vec, } struct StubMemory; #[async_trait] impl Memory for StubMemory { async fn store( &self, _namespace: &str, _key: &str, _content: &str, _category: MemoryCategory, _session_id: Option<&str>, ) -> Result<()> { Ok(()) } async fn recall( &self, _query: &str, _limit: usize, _opts: openhuman_core::openhuman::memory::RecallOpts<'_>, ) -> Result> { Ok(Vec::new()) } async fn get(&self, _namespace: &str, _key: &str) -> Result> { Ok(None) } async fn list( &self, _namespace: Option<&str>, _category: Option<&MemoryCategory>, _session_id: Option<&str>, ) -> Result> { Ok(Vec::new()) } async fn forget(&self, _namespace: &str, _key: &str) -> Result { Ok(false) } async fn namespace_summaries(&self) -> Result> { Ok(Vec::new()) } async fn count(&self) -> Result { Ok(0) } async fn health_check(&self) -> bool { true } fn name(&self) -> &str { "round22-memory" } } fn env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn tempdir() -> TempDir { std::fs::create_dir_all("target").expect("target dir"); Builder::new() .prefix("tools-composio-round22-") .tempdir_in("target") .expect("round22 tempdir") } async fn setup() -> Harness { let tmp = tempdir(); let root = tmp.path().join("openhuman"); let workspace = root.join("workspace"); std::fs::create_dir_all(&workspace).expect("workspace dir"); let guards = vec![ EnvGuard::set_path("OPENHUMAN_WORKSPACE", &root), EnvGuard::set_path("HOME", tmp.path()), EnvGuard::unset("BACKEND_URL"), EnvGuard::unset("VITE_BACKEND_URL"), EnvGuard::unset("OPENHUMAN_API_URL"), EnvGuard::unset("OPENHUMAN_CORE_RPC_URL"), EnvGuard::unset("OPENHUMAN_CORE_PORT"), EnvGuard::unset("OPENHUMAN_LSP_ENABLED"), EnvGuard::unset("OPENHUMAN_BROWSER_ALLOW_ALL"), ]; let mut config = Config { workspace_dir: workspace.clone(), config_path: root.join("config.toml"), ..Config::default() }; config.node.enabled = false; config.secrets.encrypt = false; config.observability.analytics_enabled = false; config.save().await.expect("save config"); Harness { _tmp: tmp, workspace, config, _guards: guards, } } #[tokio::test] async fn round22_direct_composio_tool_covers_summary_and_validation_edges() { let _lock = env_lock(); let state = MockState::default(); let base = start_loopback( Router::new() .fallback(any(composio_direct_handler)) .with_state(state.clone()), ) .await; let tool = ComposioTool::new_with_base_urls_for_loopback( " ck_round22 ", Some(" entity-round22 "), Arc::new(SecurityPolicy::default()), format!("{base}/api/v2"), format!("{base}/api/v3"), ) .expect("loopback composio tool"); assert!(tool.external_effect_with_args(&json!({}))); assert!(tool.external_effect_with_args(&json!({ "action": "wat" }))); let listed = tool .execute(json!({ "action": "list", "app": "bulk" })) .await .expect("list many actions"); assert!(!listed.is_error); assert!(listed.output().contains("Found 22 available actions")); assert!(listed.output().contains("... and 2 more")); assert!(listed.output().contains("bulk-action-00")); assert!(!listed.output().contains("bulk-action-21")); let missing_execute_slug = tool .execute(json!({ "action": "execute", "params": {} })) .await .expect_err("missing execute slug is an anyhow validation error"); assert!(missing_execute_slug .to_string() .contains("Missing 'action_name'")); let missing_connect_target = tool .execute(json!({ "action": "connect" })) .await .expect_err("missing connect target is an anyhow validation error"); assert!(missing_connect_target .to_string() .contains("Missing 'app' or 'auth_config_id'")); let bad_json = tool .list_actions(Some("bad-json")) .await .expect_err("bad json fails v3 and v2") .to_string(); assert!(bad_json.contains("Failed to decode Composio v3 tools response")); assert!(bad_json.contains("v2 fallback")); let requests = state.requests.lock().expect("requests").clone(); assert!(requests.iter().all(|request| { request.api_key.as_deref() == Some("ck_round22") || request.path == "/health" })); assert!(requests.iter().any(|request| { request.method == Method::GET && request.path == "/api/v3/tools" && request.query.contains("toolkits=bulk") && request.query.contains("toolkit_slug=bulk") })); } #[tokio::test] async fn round22_todo_tool_covers_crud_and_patch_error_branches() { let _lock = env_lock(); let _harness = setup().await; let tool = TodoTool::new(); let _ = tool.execute(json!({ "op": "clear" })).await; let added = tool .execute(json!({ "op": "add", "content": "Ship round22 coverage", "status": "pending", "objective": "Raise coverage on tools", "plan": ["write test", "run validation"], "assignedAgent": "coverage_worker", "allowedTools": ["cargo", "composio"], "approvalMode": null, "acceptanceCriteria": ["tests pass"], "evidence": ["target/tools-composio-round22-focused-lcov.info"], "notes": "scratch board" })) .await .expect("add todo card"); assert!(!added.is_error, "{}", added.output()); let payload: Value = serde_json::from_str(&added.output()).expect("todo json"); let id = payload["cards"][0]["id"] .as_str() .expect("todo id") .to_string(); assert!(payload["markdown"] .as_str() .unwrap_or_default() .contains("Ship round22 coverage")); let edited = tool .execute(json!({ "op": "edit", "id": id, "content": "Ship focused round22 coverage", "status": "blocked", "blocker": "waiting on validation", "approvalMode": "required" })) .await .expect("edit todo card"); assert!(!edited.is_error, "{}", edited.output()); assert!(edited.output().contains("waiting on validation")); let removed = tool .execute(json!({ "op": "remove", "id": id })) .await .expect("remove todo card"); assert!(!removed.is_error, "{}", removed.output()); let bad_array = tool .execute(json!({ "op": "add", "content": "bad plan", "plan": ["ok", 42] })) .await .expect_err("non-string plan entries are validation errors"); assert!(bad_array .to_string() .contains("`plan` must be an array of strings")); let bad_approval = tool .execute(json!({ "op": "add", "content": "bad approval", "approvalMode": "sometimes" })) .await .expect_err("bad approval mode is validation error"); assert!(bad_approval.to_string().contains("invalid approvalMode")); let invalid_replace = tool .execute(json!({ "op": "replace", "cards": [{ "id": 7 }] })) .await .expect_err("invalid replace bubbles validation error"); assert!(invalid_replace.to_string().contains("invalid `cards`")); let unknown = tool .execute(json!({ "op": "sort" })) .await .expect("unknown op returns tool result"); assert!(unknown.is_error); assert!(unknown.output().contains("unknown op")); let _ = tool.execute(json!({ "op": "clear" })).await; } #[tokio::test] async fn round22_cron_add_tool_covers_validation_and_markdown_edges() { let _lock = env_lock(); let mut harness = setup().await; let security = Arc::new(SecurityPolicy::from_config( &harness.config.autonomy, &harness.config.workspace_dir, &harness.config.workspace_dir, )); harness.config.cron.enabled = false; let disabled = CronAddTool::new(Arc::new(harness.config.clone()), security.clone()) .execute(json!({})) .await .expect("disabled cron"); assert!(disabled.is_error); assert!(disabled.output().contains("cron is disabled")); harness.config.cron.enabled = true; let tool = CronAddTool::new(Arc::new(harness.config.clone()), security); let missing_schedule = tool .execute(json!({ "command": "echo ok" })) .await .expect("missing schedule"); assert!(missing_schedule.is_error); assert!(missing_schedule.output().contains("Missing 'schedule'")); let invalid_job_type = tool .execute(json!({ "name": "bad_type", "schedule": { "kind": "every", "every_ms": 60000 }, "job_type": "timer", "command": "echo ok" })) .await .expect("invalid job type"); assert!(invalid_job_type.is_error); assert!(invalid_job_type.output().contains("Invalid job_type")); let missing_command = tool .execute(json!({ "name": "missing_command", "schedule": { "kind": "every", "every_ms": 60000 }, "job_type": "shell" })) .await .expect("missing command"); assert!(missing_command.is_error); assert!(missing_command.output().contains("Missing 'command'")); let invalid_delivery = tool .execute(json!({ "name": "bad_delivery", "schedule": { "kind": "every", "every_ms": 60000 }, "job_type": "agent", "prompt": "summarize", "delivery": { "mode": "announce", "channel": "telegram" } })) .await .expect("invalid delivery"); assert!(invalid_delivery.is_error); assert!(invalid_delivery .output() .contains("delivery.to is required")); let created = tool .execute_with_options( json!({ "name": "round22_agent_once", "schedule": { "kind": "at", "at": "2099-12-31T00:00:00Z" }, "job_type": "agent", "prompt": "collect validation notes", "session_target": "main", "model": "test-model", "delivery": { "mode": "none" } }), ToolCallOptions { prefer_markdown: true, }, ) .await .expect("create agent cron"); assert!(!created.is_error, "{}", created.output()); assert!(created .markdown_formatted .as_deref() .unwrap_or_default() .contains("Created cron job")); assert!(created.output().contains("\"enabled\": true")); let delivery: DeliveryConfig = serde_json::from_value(json!({ "mode": "none" })).expect("delivery defaults deserialize"); assert_eq!(delivery.mode, "none"); } #[tokio::test] async fn round22_tool_registry_covers_config_gated_registration() { let _lock = env_lock(); let mut harness = setup().await; harness.config.browser.enabled = true; harness.config.http_request.allowed_domains = vec![ "*".to_string(), "example.com".to_string(), "docs.example.com".to_string(), ]; harness.config.computer_control.enabled = true; harness.config.learning.enabled = true; harness.config.learning.tool_tracking_enabled = true; harness.config.gitbooks.enabled = true; harness.config.node.enabled = false; let security = Arc::new(SecurityPolicy::from_config( &harness.config.autonomy, &harness.workspace, &harness.workspace, )); let memory: Arc = Arc::new(StubMemory); let audit = AuditLogger::disabled(); let agents: HashMap = HashMap::new(); let tools = all_tools( Arc::new(harness.config.clone()), &security, audit, memory, &harness.config.browser, &harness.config.http_request, &harness.workspace, &agents, &harness.config, ); let names: Vec<&str> = tools.iter().map(|tool| tool.name()).collect(); assert!(names.contains(&"browser_open")); assert!(names.contains(&"browser")); assert!(names.contains(&"http_request")); assert!(names.contains(&"web_fetch")); assert!(names.contains(&"curl")); assert!(names.contains(&"gitbooks_search")); assert!(names.contains(&"gitbooks_get_page")); assert!(names.contains(&"mouse")); assert!(names.contains(&"keyboard")); assert!(names.contains(&"tool_stats")); assert!(!names.contains(&"node_exec")); assert!(!names.contains(&"npm_exec")); } async fn start_loopback(app: Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind loopback"); let addr = listener.local_addr().expect("loopback addr"); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve loopback"); }); format!("http://127.0.0.1:{}", addr.port()) } async fn composio_direct_handler(State(state): State, request: Request) -> Response { let method = request.method().clone(); let uri = request.uri().clone(); let path = uri.path().to_string(); let query = uri.query().unwrap_or_default().to_string(); let api_key = request .headers() .get("x-api-key") .and_then(|value| value.to_str().ok()) .map(str::to_string); let bytes = to_bytes(request.into_body(), usize::MAX) .await .expect("request body"); let _body: Value = if bytes.is_empty() { json!({}) } else { serde_json::from_slice(&bytes).unwrap_or_else(|_| json!(String::from_utf8_lossy(&bytes))) }; state .requests .lock() .expect("requests") .push(RecordedRequest { method: method.clone(), path: path.clone(), query: query.clone(), api_key, }); match (method, path.as_str()) { (Method::GET, "/api/v3/tools") if query.contains("toolkits=bulk") => { let items: Vec = (0..22) .map(|idx| { json!({ "slug": format!("bulk-action-{idx:02}"), "name": format!("Bulk action {idx:02}"), "description": format!("Bulk description {idx:02}"), "toolkit": { "slug": "bulk" }, }) }) .collect(); ok(json!({ "items": items })) } (Method::GET, "/api/v3/tools") if query.contains("toolkits=bad-json") => { text(StatusCode::OK, "{not-json") } (Method::GET, "/api/v2/actions") if query.contains("bad-json") => { fail(StatusCode::BAD_GATEWAY, "v2 unavailable") } (Method::GET, "/api/v2/actions") => ok(json!({ "items": [] })), _ => fail(StatusCode::NOT_FOUND, &format!("unhandled {path}")), } } fn ok(value: Value) -> Response { Json(value).into_response() } fn fail(status: StatusCode, message: &str) -> Response { (status, Json(json!({ "error": { "message": message } }))).into_response() } fn text(status: StatusCode, body: &str) -> Response { (status, body.to_string()).into_response() }