Files
openhuman/tests/tools_composio_round22_raw_coverage_e2e.rs
T

573 lines
18 KiB
Rust

//! 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<Mutex<()>> = OnceLock::new();
#[derive(Clone, Debug)]
struct RecordedRequest {
method: Method,
path: String,
query: String,
api_key: Option<String>,
}
#[derive(Clone, Default)]
struct MockState {
requests: Arc<Mutex<Vec<RecordedRequest>>>,
}
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
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<EnvGuard>,
}
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<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn get(&self, _namespace: &str, _key: &str) -> Result<Option<MemoryEntry>> {
Ok(None)
}
async fn list(
&self,
_namespace: Option<&str>,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn forget(&self, _namespace: &str, _key: &str) -> Result<bool> {
Ok(false)
}
async fn namespace_summaries(&self) -> Result<Vec<NamespaceSummary>> {
Ok(Vec::new())
}
async fn count(&self) -> Result<usize> {
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<dyn Memory> = Arc::new(StubMemory);
let audit = AuditLogger::disabled();
let agents: HashMap<String, DelegateAgentConfig> = 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<MockState>, 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<Value> = (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()
}