mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
573 lines
18 KiB
Rust
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()
|
|
}
|