mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
1275 lines
38 KiB
Rust
1275 lines
38 KiB
Rust
//! Focused Rust E2E coverage for Worker C module ownership.
|
|
//!
|
|
//! This suite intentionally stays inside the memory / memory_sync / channels /
|
|
//! composio / threads slice and drives the real HTTP JSON-RPC router against
|
|
//! an isolated workspace. It avoids live network calls.
|
|
|
|
use std::path::Path;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::header::{AUTHORIZATION, CONTENT_TYPE};
|
|
use axum::{response::Html, routing::get, Json, Router};
|
|
use reqwest::StatusCode;
|
|
use serde_json::{json, Value};
|
|
use tempfile::{tempdir, TempDir};
|
|
|
|
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
|
|
use openhuman_core::core::jsonrpc::build_core_http_router;
|
|
|
|
const TEST_RPC_TOKEN: &str = "worker-c-modules-e2e-token";
|
|
|
|
static AUTH_INIT: OnceLock<()> = OnceLock::new();
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set_to_path(key: &'static str, path: &Path) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, path.as_os_str()) };
|
|
Self { key, old }
|
|
}
|
|
|
|
fn set(key: &'static str, value: &str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, value) };
|
|
Self { key, old }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::remove_var(key) };
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.old {
|
|
Some(value) => unsafe { std::env::set_var(self.key, value) },
|
|
None => unsafe { std::env::remove_var(self.key) },
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Harness {
|
|
rpc_base: String,
|
|
_tmp: TempDir,
|
|
_guards: Vec<EnvVarGuard>,
|
|
join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
}
|
|
|
|
impl Drop for Harness {
|
|
fn drop(&mut self) {
|
|
self.join.abort();
|
|
}
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
|
match mutex.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
fn ensure_rpc_auth() {
|
|
AUTH_INIT.get_or_init(|| {
|
|
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
|
|
let token_dir = std::env::temp_dir().join("openhuman-worker-c-e2e-auth");
|
|
init_rpc_token(&token_dir).expect("init rpc auth token");
|
|
});
|
|
}
|
|
|
|
fn write_config(openhuman_dir: &Path) {
|
|
std::fs::create_dir_all(openhuman_dir).expect("create .openhuman");
|
|
let cfg = r#"api_url = "http://127.0.0.1:9"
|
|
default_model = "worker-c-e2e-model"
|
|
default_temperature = 0.2
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
|
|
[local_ai]
|
|
enabled = false
|
|
|
|
[memory]
|
|
provider = "none"
|
|
embedding_provider = "none"
|
|
embedding_model = "none"
|
|
embedding_dimensions = 0
|
|
|
|
[memory_tree]
|
|
embedding_strict = false
|
|
"#;
|
|
std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml");
|
|
let _: openhuman_core::openhuman::config::Config =
|
|
toml::from_str(cfg).expect("test config must match schema");
|
|
}
|
|
|
|
async fn setup() -> Harness {
|
|
ensure_rpc_auth();
|
|
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path();
|
|
write_config(&home.join(".openhuman"));
|
|
|
|
let guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", home),
|
|
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
|
|
EnvVarGuard::unset("BACKEND_URL"),
|
|
EnvVarGuard::unset("VITE_BACKEND_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_API_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_COMPOSIO_DIRECT_BASE_V2"),
|
|
EnvVarGuard::unset("OPENHUMAN_COMPOSIO_DIRECT_BASE_V3"),
|
|
EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
|
|
];
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind rpc listener");
|
|
let addr = listener.local_addr().expect("rpc listener addr");
|
|
let router = build_core_http_router(false);
|
|
let join = tokio::spawn(async move { axum::serve(listener, router).await });
|
|
|
|
Harness {
|
|
rpc_base: format!("http://{addr}"),
|
|
_tmp: tmp,
|
|
_guards: guards,
|
|
join,
|
|
}
|
|
}
|
|
|
|
async fn rpc(base: &str, id: i64, method: &str, params: Value) -> Value {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.expect("client");
|
|
let url = format!("{}/rpc", base.trim_end_matches('/'));
|
|
let response = client
|
|
.post(&url)
|
|
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
|
.json(&json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params,
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|err| panic!("POST {url} {method}: {err}"));
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"HTTP transport should accept {method}"
|
|
);
|
|
response
|
|
.json::<Value>()
|
|
.await
|
|
.unwrap_or_else(|err| panic!("json for {method}: {err}"))
|
|
}
|
|
|
|
fn ok<'a>(value: &'a Value, context: &str) -> &'a Value {
|
|
if let Some(error) = value.get("error") {
|
|
panic!("{context}: unexpected JSON-RPC error: {error}");
|
|
}
|
|
value
|
|
.get("result")
|
|
.unwrap_or_else(|| panic!("{context}: missing result: {value}"))
|
|
}
|
|
|
|
fn payload<'a>(value: &'a Value, context: &str) -> &'a Value {
|
|
let outer = ok(value, context);
|
|
outer
|
|
.get("data")
|
|
.or_else(|| outer.get("result"))
|
|
.unwrap_or(outer)
|
|
}
|
|
|
|
fn error_message<'a>(value: &'a Value, context: &str) -> &'a str {
|
|
value
|
|
.get("error")
|
|
.and_then(|error| error.get("message"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("{context}: expected JSON-RPC error with message: {value}"))
|
|
}
|
|
|
|
fn assert_rpc_completed(value: &Value, context: &str) {
|
|
assert!(
|
|
value.get("result").is_some() || value.get("error").is_some(),
|
|
"{context}: expected JSON-RPC result or error envelope: {value}"
|
|
);
|
|
}
|
|
|
|
fn find_status_entry<'a>(entries: &'a [Value], channel: &str, auth_mode: &str) -> &'a Value {
|
|
entries
|
|
.iter()
|
|
.find(|entry| {
|
|
entry.get("channel_id").and_then(Value::as_str) == Some(channel)
|
|
&& entry.get("auth_mode").and_then(Value::as_str) == Some(auth_mode)
|
|
})
|
|
.unwrap_or_else(|| panic!("missing status entry for {channel}/{auth_mode}: {entries:?}"))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channels_imessage_config_only_connection_reports_status_and_disconnects() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let described = rpc(
|
|
&harness.rpc_base,
|
|
1,
|
|
"openhuman.channels_describe",
|
|
json!({ "channel": "imessage" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&described, "channels_describe")
|
|
.get("id")
|
|
.and_then(Value::as_str),
|
|
Some("imessage")
|
|
);
|
|
|
|
let baseline = rpc(
|
|
&harness.rpc_base,
|
|
2,
|
|
"openhuman.channels_status",
|
|
json!({ "channel": "imessage" }),
|
|
)
|
|
.await;
|
|
let baseline_entries = payload(&baseline, "channels_status baseline")
|
|
.as_array()
|
|
.expect("status entries");
|
|
assert_eq!(
|
|
find_status_entry(baseline_entries, "imessage", "managed_dm")
|
|
.get("connected")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
let connected = rpc(
|
|
&harness.rpc_base,
|
|
3,
|
|
"openhuman.channels_connect",
|
|
json!({
|
|
"channel": "imessage",
|
|
"authMode": "managed_dm",
|
|
"credentials": { "allowed_contacts": "alice@example.com, +15550100" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&connected, "channels_connect imessage")
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("connected")
|
|
);
|
|
|
|
let after_connect = rpc(
|
|
&harness.rpc_base,
|
|
4,
|
|
"openhuman.channels_status",
|
|
json!({ "channel": "imessage" }),
|
|
)
|
|
.await;
|
|
let connected_entries = payload(&after_connect, "channels_status after connect")
|
|
.as_array()
|
|
.expect("status entries");
|
|
assert_eq!(
|
|
find_status_entry(connected_entries, "imessage", "managed_dm")
|
|
.get("connected")
|
|
.and_then(Value::as_bool),
|
|
Some(true),
|
|
"config-only iMessage connection must be visible through channels_status"
|
|
);
|
|
|
|
let disconnected = rpc(
|
|
&harness.rpc_base,
|
|
5,
|
|
"openhuman.channels_disconnect",
|
|
json!({
|
|
"channel": "imessage",
|
|
"authMode": "managed_dm",
|
|
"clearMemory": false
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&disconnected, "channels_disconnect imessage")
|
|
.get("disconnected")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let after_disconnect = rpc(
|
|
&harness.rpc_base,
|
|
6,
|
|
"openhuman.channels_status",
|
|
json!({ "channel": "imessage" }),
|
|
)
|
|
.await;
|
|
let disconnected_entries = payload(&after_disconnect, "channels_status after disconnect")
|
|
.as_array()
|
|
.expect("status entries");
|
|
assert_eq!(
|
|
find_status_entry(disconnected_entries, "imessage", "managed_dm")
|
|
.get("connected")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channels_remaining_controller_paths_validate_without_live_services() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
for (id, method) in [
|
|
(40, "openhuman.channels_test"),
|
|
(41, "openhuman.channels_telegram_login_check"),
|
|
(42, "openhuman.channels_discord_link_check"),
|
|
(43, "openhuman.channels_discord_list_channels"),
|
|
(44, "openhuman.channels_discord_check_permissions"),
|
|
(45, "openhuman.channels_send_message"),
|
|
(46, "openhuman.channels_send_reaction"),
|
|
(47, "openhuman.channels_create_thread"),
|
|
(48, "openhuman.channels_update_thread"),
|
|
(49, "openhuman.channels_list_threads"),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, json!({})).await;
|
|
assert!(
|
|
response.get("error").is_some(),
|
|
"{method} should reject missing required params: {response}"
|
|
);
|
|
}
|
|
|
|
for (id, method) in [
|
|
(50, "openhuman.channels_telegram_login_start"),
|
|
(51, "openhuman.channels_discord_link_start"),
|
|
(52, "openhuman.channels_discord_list_guilds"),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, json!({})).await;
|
|
assert_rpc_completed(&response, method);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn composio_direct_mode_api_key_and_static_catalogs_round_trip() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
let (composio_base, composio_hits, composio_join) = serve_composio_direct_fixtures().await;
|
|
let _composio_v2_guard = EnvVarGuard::set("OPENHUMAN_COMPOSIO_DIRECT_BASE_V2", &composio_base);
|
|
let _composio_v3_guard = EnvVarGuard::set("OPENHUMAN_COMPOSIO_DIRECT_BASE_V3", &composio_base);
|
|
|
|
let capabilities = rpc(
|
|
&harness.rpc_base,
|
|
10,
|
|
"openhuman.composio_list_capabilities",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let capability_rows = payload(&capabilities, "composio_list_capabilities")
|
|
.get("capabilities")
|
|
.and_then(Value::as_array)
|
|
.expect("capabilities array");
|
|
assert!(
|
|
capability_rows.iter().any(|row| {
|
|
row.get("toolkit").and_then(Value::as_str) == Some("gmail")
|
|
|| row.get("toolkit").and_then(Value::as_str) == Some("github")
|
|
}),
|
|
"static capability matrix should expose common toolkits: {capability_rows:?}"
|
|
);
|
|
|
|
let agent_ready = rpc(
|
|
&harness.rpc_base,
|
|
11,
|
|
"openhuman.composio_list_agent_ready_toolkits",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let ready_toolkits = payload(&agent_ready, "composio_list_agent_ready_toolkits")
|
|
.get("toolkits")
|
|
.and_then(Value::as_array)
|
|
.expect("agent-ready toolkits");
|
|
assert!(
|
|
ready_toolkits
|
|
.iter()
|
|
.any(|toolkit| toolkit.as_str() == Some("gmail")),
|
|
"gmail should remain in the agent-ready catalog: {ready_toolkits:?}"
|
|
);
|
|
|
|
let mode0 = rpc(
|
|
&harness.rpc_base,
|
|
12,
|
|
"openhuman.composio_get_mode",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&mode0, "composio_get_mode initial")
|
|
.get("api_key_set")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
let set = rpc(
|
|
&harness.rpc_base,
|
|
13,
|
|
"openhuman.composio_set_api_key",
|
|
json!({
|
|
"api_key": "cmp_worker_c_test_key",
|
|
"activate_direct": true
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&set, "composio_set_api_key")
|
|
.get("mode")
|
|
.and_then(Value::as_str),
|
|
Some("direct")
|
|
);
|
|
assert_eq!(
|
|
composio_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"saving a direct-mode API key should validate the candidate key against the v3 mock"
|
|
);
|
|
|
|
let mode1 = rpc(
|
|
&harness.rpc_base,
|
|
14,
|
|
"openhuman.composio_get_mode",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&mode1, "composio_get_mode direct")
|
|
.get("api_key_set")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
payload(&mode1, "composio_get_mode direct")
|
|
.get("mode")
|
|
.and_then(Value::as_str),
|
|
Some("direct")
|
|
);
|
|
|
|
let toolkits = rpc(
|
|
&harness.rpc_base,
|
|
15,
|
|
"openhuman.composio_list_toolkits",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&toolkits, "composio_list_toolkits direct")
|
|
.get("toolkits")
|
|
.and_then(Value::as_array)
|
|
.expect("toolkits array")
|
|
.is_empty(),
|
|
"direct mode should not call the backend tenant allowlist"
|
|
);
|
|
|
|
let cleared = rpc(
|
|
&harness.rpc_base,
|
|
16,
|
|
"openhuman.composio_clear_api_key",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&cleared, "composio_clear_api_key")
|
|
.get("mode")
|
|
.and_then(Value::as_str),
|
|
Some("backend")
|
|
);
|
|
composio_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn composio_remaining_controller_paths_validate_without_live_services() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
for (id, method) in [
|
|
(60, "openhuman.composio_authorize"),
|
|
(61, "openhuman.composio_delete_connection"),
|
|
(62, "openhuman.composio_execute"),
|
|
(63, "openhuman.composio_list_github_repos"),
|
|
(64, "openhuman.composio_create_trigger"),
|
|
(65, "openhuman.composio_get_user_profile"),
|
|
(66, "openhuman.composio_sync"),
|
|
(67, "openhuman.composio_get_user_scopes"),
|
|
(68, "openhuman.composio_set_user_scopes"),
|
|
(69, "openhuman.composio_list_available_triggers"),
|
|
(70, "openhuman.composio_enable_trigger"),
|
|
(71, "openhuman.composio_disable_trigger"),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, json!({})).await;
|
|
assert!(
|
|
response.get("error").is_some(),
|
|
"{method} should reject missing required params: {response}"
|
|
);
|
|
}
|
|
|
|
for (id, method) in [
|
|
(72, "openhuman.composio_list_connections"),
|
|
(73, "openhuman.composio_list_tools"),
|
|
(74, "openhuman.composio_list_trigger_history"),
|
|
(75, "openhuman.composio_refresh_all_identities"),
|
|
(76, "openhuman.composio_list_triggers"),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, json!({})).await;
|
|
assert_rpc_completed(&response, method);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn threads_message_lifecycle_is_persisted_and_validated() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let upsert = rpc(
|
|
&harness.rpc_base,
|
|
20,
|
|
"openhuman.threads_upsert",
|
|
json!({
|
|
"id": "worker-c-thread",
|
|
"title": "Worker C thread",
|
|
"created_at": "2026-05-29T12:00:00Z",
|
|
"labels": ["worker-c", "e2e"]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&upsert, "threads_upsert")
|
|
.get("id")
|
|
.and_then(Value::as_str),
|
|
Some("worker-c-thread")
|
|
);
|
|
|
|
let append = rpc(
|
|
&harness.rpc_base,
|
|
21,
|
|
"openhuman.threads_message_append",
|
|
json!({
|
|
"thread_id": "worker-c-thread",
|
|
"message": {
|
|
"id": "worker-c-message",
|
|
"content": "Persist this Worker C message",
|
|
"type": "text",
|
|
"extraMetadata": { "phase": "initial" },
|
|
"sender": "user",
|
|
"createdAt": "2026-05-29T12:00:01Z"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&append, "threads_message_append")
|
|
.get("id")
|
|
.and_then(Value::as_str),
|
|
Some("worker-c-message")
|
|
);
|
|
|
|
let listed = rpc(
|
|
&harness.rpc_base,
|
|
22,
|
|
"openhuman.threads_messages_list",
|
|
json!({ "thread_id": "worker-c-thread" }),
|
|
)
|
|
.await;
|
|
let messages = payload(&listed, "threads_messages_list")
|
|
.get("messages")
|
|
.and_then(Value::as_array)
|
|
.expect("messages array");
|
|
assert_eq!(messages.len(), 1);
|
|
assert_eq!(
|
|
messages[0].get("content").and_then(Value::as_str),
|
|
Some("Persist this Worker C message")
|
|
);
|
|
|
|
let updated = rpc(
|
|
&harness.rpc_base,
|
|
23,
|
|
"openhuman.threads_message_update",
|
|
json!({
|
|
"thread_id": "worker-c-thread",
|
|
"message_id": "worker-c-message",
|
|
"extra_metadata": { "phase": "updated", "verified": true }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&updated, "threads_message_update").pointer("/extraMetadata/verified"),
|
|
Some(&json!(true))
|
|
);
|
|
|
|
let missing_list = rpc(
|
|
&harness.rpc_base,
|
|
24,
|
|
"openhuman.threads_messages_list",
|
|
json!({ "thread_id": "missing-thread" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&missing_list, "threads_messages_list missing")
|
|
.get("count")
|
|
.and_then(Value::as_u64),
|
|
Some(0),
|
|
"listing a missing thread is a read-only empty result"
|
|
);
|
|
|
|
let missing_append = rpc(
|
|
&harness.rpc_base,
|
|
25,
|
|
"openhuman.threads_message_append",
|
|
json!({
|
|
"thread_id": "missing-thread",
|
|
"message": {
|
|
"id": "missing-message",
|
|
"content": "This should not persist",
|
|
"type": "text",
|
|
"extraMetadata": {},
|
|
"sender": "user",
|
|
"createdAt": "2026-05-29T12:00:02Z"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
error_message(&missing_append, "threads_message_append missing").contains("not found"),
|
|
"mutating a missing thread should return a structured JSON-RPC error: {missing_append}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn threads_remaining_controller_paths_round_trip() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let created = rpc(
|
|
&harness.rpc_base,
|
|
80,
|
|
"openhuman.threads_create_new",
|
|
json!({ "labels": ["worker-c"] }),
|
|
)
|
|
.await;
|
|
let created_thread_id = payload(&created, "threads_create_new")
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.expect("created thread id")
|
|
.to_string();
|
|
|
|
let titled = rpc(
|
|
&harness.rpc_base,
|
|
81,
|
|
"openhuman.threads_update_title",
|
|
json!({ "thread_id": created_thread_id, "title": "Worker C titled thread" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&titled, "threads_update_title")
|
|
.get("title")
|
|
.and_then(Value::as_str),
|
|
Some("Worker C titled thread")
|
|
);
|
|
|
|
let labeled = rpc(
|
|
&harness.rpc_base,
|
|
82,
|
|
"openhuman.threads_update_labels",
|
|
json!({ "thread_id": created_thread_id, "labels": ["worker-c", "remaining"] }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&labeled, "threads_update_labels")
|
|
.get("labels")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::len),
|
|
Some(2)
|
|
);
|
|
|
|
let generated = rpc(
|
|
&harness.rpc_base,
|
|
83,
|
|
"openhuman.threads_generate_title",
|
|
json!({ "thread_id": created_thread_id }),
|
|
)
|
|
.await;
|
|
assert_rpc_completed(&generated, "threads_generate_title");
|
|
|
|
let turn_state = rpc(
|
|
&harness.rpc_base,
|
|
84,
|
|
"openhuman.threads_turn_state_get",
|
|
json!({ "thread_id": created_thread_id }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&turn_state, "threads_turn_state_get").get("turnState"),
|
|
None,
|
|
"fresh thread should not have a live turn-state snapshot"
|
|
);
|
|
|
|
let turn_states = rpc(
|
|
&harness.rpc_base,
|
|
85,
|
|
"openhuman.threads_turn_state_list",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(payload(&turn_states, "threads_turn_state_list")
|
|
.get("turnStates")
|
|
.and_then(Value::as_array)
|
|
.is_some());
|
|
|
|
let clear = rpc(
|
|
&harness.rpc_base,
|
|
86,
|
|
"openhuman.threads_turn_state_clear",
|
|
json!({ "thread_id": created_thread_id }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&clear, "threads_turn_state_clear")
|
|
.get("cleared")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
let board_put = rpc(
|
|
&harness.rpc_base,
|
|
87,
|
|
"openhuman.threads_task_board_put",
|
|
json!({
|
|
"thread_id": created_thread_id,
|
|
"cards": [{
|
|
"id": "card-1",
|
|
"title": "Verify remaining controller paths",
|
|
"status": "todo",
|
|
"order": 1
|
|
}]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&board_put, "threads_task_board_put")
|
|
.pointer("/taskBoard/cards/0/id")
|
|
.and_then(Value::as_str),
|
|
Some("card-1")
|
|
);
|
|
|
|
let board_get = rpc(
|
|
&harness.rpc_base,
|
|
88,
|
|
"openhuman.threads_task_board_get",
|
|
json!({ "thread_id": created_thread_id }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&board_get, "threads_task_board_get")
|
|
.pointer("/taskBoard/cards/0/title")
|
|
.and_then(Value::as_str),
|
|
Some("Verify remaining controller paths")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn embeddings_controller_paths_validate_without_live_services() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let updated = rpc(
|
|
&harness.rpc_base,
|
|
90,
|
|
"openhuman.embeddings_update_settings",
|
|
json!({
|
|
"provider": "none",
|
|
"model": "none",
|
|
"dimensions": 0,
|
|
"confirm_wipe": true
|
|
}),
|
|
)
|
|
.await;
|
|
assert_rpc_completed(&updated, "embeddings_update_settings");
|
|
|
|
for (id, method) in [
|
|
(91, "openhuman.embeddings_set_api_key"),
|
|
(92, "openhuman.embeddings_clear_api_key"),
|
|
(93, "openhuman.embeddings_embed"),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, json!({})).await;
|
|
assert!(
|
|
response.get("error").is_some(),
|
|
"{method} should reject missing required params: {response}"
|
|
);
|
|
}
|
|
|
|
let tested = rpc(
|
|
&harness.rpc_base,
|
|
94,
|
|
"openhuman.embeddings_test_connection",
|
|
json!({ "provider": "none", "model": "none", "dimensions": 0 }),
|
|
)
|
|
.await;
|
|
assert_rpc_completed(&tested, "embeddings_test_connection");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_tree_ingest_feeds_memory_sync_status() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let ingest = rpc(
|
|
&harness.rpc_base,
|
|
30,
|
|
"openhuman.memory_tree_ingest",
|
|
json!({
|
|
"source_kind": "chat",
|
|
"source_id": "slack:worker-c",
|
|
"owner": "worker-c@example.com",
|
|
"tags": ["worker-c", "memory-sync"],
|
|
"payload": {
|
|
"platform": "slack",
|
|
"channel_label": "worker-c",
|
|
"messages": [
|
|
{
|
|
"author": "alice@example.com",
|
|
"text": "Worker C coverage confirms memory sync status after ingest.",
|
|
"timestamp": 1780000000000_i64,
|
|
"source_ref": "slack://worker-c/msg-1"
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let ingest_payload = payload(&ingest, "memory_tree_ingest");
|
|
assert_eq!(
|
|
ingest_payload.get("source_id").and_then(Value::as_str),
|
|
Some("slack:worker-c")
|
|
);
|
|
assert_eq!(
|
|
ingest_payload.get("chunks_written").and_then(Value::as_u64),
|
|
Some(1)
|
|
);
|
|
|
|
let statuses = rpc(
|
|
&harness.rpc_base,
|
|
31,
|
|
"openhuman.memory_sync_status_list",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let rows = payload(&statuses, "memory_sync_status_list")
|
|
.get("statuses")
|
|
.and_then(Value::as_array)
|
|
.expect("statuses array");
|
|
let slack = rows
|
|
.iter()
|
|
.find(|row| row.get("provider").and_then(Value::as_str) == Some("slack"))
|
|
.unwrap_or_else(|| panic!("expected slack status row after ingest: {rows:?}"));
|
|
assert_eq!(slack.get("chunks_synced").and_then(Value::as_u64), Some(1));
|
|
assert_eq!(
|
|
slack.get("chunks_pending").and_then(Value::as_u64),
|
|
Some(1),
|
|
"inert embeddings leave the fresh chunk pending until an embed sidecar exists"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_memory_tree_and_sources_controller_surfaces_are_reachable() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let methods = [
|
|
"openhuman.memory_init",
|
|
"openhuman.memory_sync_all",
|
|
"openhuman.memory_sync_channel",
|
|
"openhuman.memory_ingestion_status",
|
|
"openhuman.memory_list_files",
|
|
"openhuman.memory_read_file",
|
|
"openhuman.memory_write_file",
|
|
"openhuman.memory_list_namespaces",
|
|
"openhuman.memory_query_namespace",
|
|
"openhuman.memory_clear_namespace",
|
|
"openhuman.memory_recall_memories",
|
|
"openhuman.memory_recall_context",
|
|
"openhuman.memory_context_query",
|
|
"openhuman.memory_context_recall",
|
|
"openhuman.memory_doc_put",
|
|
"openhuman.memory_doc_ingest",
|
|
"openhuman.memory_doc_list",
|
|
"openhuman.memory_doc_delete",
|
|
"openhuman.memory_list_documents",
|
|
"openhuman.memory_delete_document",
|
|
"openhuman.memory_namespace_list",
|
|
"openhuman.memory_kv_set",
|
|
"openhuman.memory_kv_get",
|
|
"openhuman.memory_kv_delete",
|
|
"openhuman.memory_kv_list_namespace",
|
|
"openhuman.memory_graph_upsert",
|
|
"openhuman.memory_graph_query",
|
|
"openhuman.memory_tool_rule_put",
|
|
"openhuman.memory_tool_rule_get",
|
|
"openhuman.memory_tool_rule_delete",
|
|
"openhuman.memory_tool_rule_list",
|
|
"openhuman.memory_tool_rules_for_prompt",
|
|
"openhuman.memory_tool_rules_json",
|
|
"openhuman.memory_learn_all",
|
|
"openhuman.memory_tree_pipeline_status",
|
|
"openhuman.memory_tree_ingest",
|
|
"openhuman.memory_tree_search",
|
|
"openhuman.memory_tree_recall",
|
|
"openhuman.memory_tree_list_sources",
|
|
"openhuman.memory_tree_list_chunks",
|
|
"openhuman.memory_tree_get_chunk",
|
|
"openhuman.memory_tree_delete_chunk",
|
|
"openhuman.memory_tree_top_entities",
|
|
"openhuman.memory_tree_chunks_for_entity",
|
|
"openhuman.memory_tree_graph_export",
|
|
"openhuman.memory_tree_entity_index_for",
|
|
"openhuman.memory_tree_memory_backfill_status",
|
|
"openhuman.memory_tree_obsidian_vault_status",
|
|
"openhuman.memory_tree_flush_now",
|
|
"openhuman.memory_tree_reset_tree",
|
|
"openhuman.memory_tree_wipe_all",
|
|
"openhuman.memory_tree_set_enabled",
|
|
"openhuman.memory_tree_query_source",
|
|
"openhuman.memory_tree_search_entities",
|
|
"openhuman.memory_tree_drill_down",
|
|
"openhuman.memory_tree_fetch_leaves",
|
|
"openhuman.memory_tree_chunk_score",
|
|
"openhuman.memory_sources_list",
|
|
"openhuman.memory_sources_add",
|
|
"openhuman.memory_sources_get",
|
|
"openhuman.memory_sources_update",
|
|
"openhuman.memory_sources_remove",
|
|
"openhuman.memory_sources_sync",
|
|
"openhuman.memory_sources_status_list",
|
|
"openhuman.memory_sources_list_items",
|
|
"openhuman.memory_sources_read_item",
|
|
];
|
|
|
|
for (offset, method) in methods.into_iter().enumerate() {
|
|
let response = rpc(&harness.rpc_base, 100 + offset as i64, method, json!({})).await;
|
|
assert_rpc_completed(&response, method);
|
|
}
|
|
}
|
|
|
|
async fn serve_source_fixtures() -> (String, tokio::task::JoinHandle<Result<(), std::io::Error>>) {
|
|
async fn page() -> Html<&'static str> {
|
|
Html(
|
|
r#"<html>
|
|
<head><title>Worker C page</title></head>
|
|
<body>
|
|
<nav>Navigation text should be ignored</nav>
|
|
<article>
|
|
<h1>Selected coverage article</h1>
|
|
<p>Web page reader extracts only the requested article body.</p>
|
|
</article>
|
|
</body>
|
|
</html>"#,
|
|
)
|
|
}
|
|
|
|
async fn feed() -> impl axum::response::IntoResponse {
|
|
(
|
|
[(CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
|
|
r#"<?xml version="1.0"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<title>Worker C Feed</title>
|
|
<item>
|
|
<title>RSS first item</title>
|
|
<guid>rss-worker-c-1</guid>
|
|
<link>https://example.test/rss/1</link>
|
|
<description>RSS body & decoded entity for coverage.</description>
|
|
<pubDate>Fri, 29 May 2026 12:00:00 GMT</pubDate>
|
|
</item>
|
|
<item>
|
|
<title>RSS second item</title>
|
|
<guid>rss-worker-c-2</guid>
|
|
<description><![CDATA[<p>HTML-like RSS content</p>]]></description>
|
|
</item>
|
|
</channel>
|
|
</rss>"#,
|
|
)
|
|
}
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind fixture listener");
|
|
let addr = listener.local_addr().expect("fixture listener addr");
|
|
let app = Router::new()
|
|
.route("/page", get(page))
|
|
.route("/feed", get(feed));
|
|
let join = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(format!("http://{addr}"), join)
|
|
}
|
|
|
|
async fn serve_composio_direct_fixtures() -> (
|
|
String,
|
|
Arc<AtomicUsize>,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
async fn connected_accounts(State(hits): State<Arc<AtomicUsize>>) -> Json<Value> {
|
|
hits.fetch_add(1, Ordering::SeqCst);
|
|
Json(json!({
|
|
"items": []
|
|
}))
|
|
}
|
|
|
|
let hits = Arc::new(AtomicUsize::new(0));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind composio fixture listener");
|
|
let addr = listener
|
|
.local_addr()
|
|
.expect("composio fixture listener addr");
|
|
let app = Router::new()
|
|
.route("/connected_accounts", get(connected_accounts))
|
|
.with_state(hits.clone());
|
|
let join = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(format!("http://{addr}"), hits, join)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_sources_folder_web_and_rss_readers_sync_through_rpc() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
let (fixture_base, fixture_join) = serve_source_fixtures().await;
|
|
|
|
let notes_dir = harness._tmp.path().join("source-notes");
|
|
std::fs::create_dir_all(notes_dir.join("nested")).expect("mkdir source notes");
|
|
std::fs::write(
|
|
notes_dir.join("overview.md"),
|
|
"# Overview\nFolder reader markdown body.",
|
|
)
|
|
.expect("write markdown note");
|
|
std::fs::write(
|
|
notes_dir.join("nested").join("brief.html"),
|
|
"<main><p>Folder reader html body.</p></main>",
|
|
)
|
|
.expect("write html note");
|
|
std::fs::write(
|
|
harness._tmp.path().join("outside-secret.md"),
|
|
"path traversal must not read this",
|
|
)
|
|
.expect("write outside note");
|
|
|
|
let folder = rpc(
|
|
&harness.rpc_base,
|
|
300,
|
|
"openhuman.memory_sources_add",
|
|
json!({
|
|
"kind": "folder",
|
|
"label": "Worker C folder",
|
|
"path": notes_dir.to_string_lossy(),
|
|
"glob": "**/*.*"
|
|
}),
|
|
)
|
|
.await;
|
|
let folder_id = payload(&folder, "memory_sources_add folder")
|
|
.pointer("/source/id")
|
|
.and_then(Value::as_str)
|
|
.expect("folder source id")
|
|
.to_string();
|
|
|
|
let folder_items = rpc(
|
|
&harness.rpc_base,
|
|
301,
|
|
"openhuman.memory_sources_list_items",
|
|
json!({ "source_id": folder_id }),
|
|
)
|
|
.await;
|
|
let folder_item_ids: Vec<&str> = payload(&folder_items, "memory_sources_list_items folder")
|
|
.get("items")
|
|
.and_then(Value::as_array)
|
|
.expect("folder items")
|
|
.iter()
|
|
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
|
.collect();
|
|
assert!(folder_item_ids.contains(&"overview.md"));
|
|
assert!(folder_item_ids.contains(&"nested/brief.html"));
|
|
|
|
let html_read = rpc(
|
|
&harness.rpc_base,
|
|
302,
|
|
"openhuman.memory_sources_read_item",
|
|
json!({ "source_id": folder_id, "item_id": "nested/brief.html" }),
|
|
)
|
|
.await;
|
|
let html_content = payload(&html_read, "memory_sources_read_item folder html")
|
|
.get("content")
|
|
.expect("folder html content");
|
|
assert_eq!(
|
|
html_content.get("content_type").and_then(Value::as_str),
|
|
Some("html")
|
|
);
|
|
assert!(html_content
|
|
.get("body")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.contains("Folder reader html body"));
|
|
|
|
let traversal = rpc(
|
|
&harness.rpc_base,
|
|
303,
|
|
"openhuman.memory_sources_read_item",
|
|
json!({ "source_id": folder_id, "item_id": "../outside-secret.md" }),
|
|
)
|
|
.await;
|
|
assert!(
|
|
error_message(&traversal, "memory_sources_read_item traversal").contains("denied"),
|
|
"folder reader should reject traversal outside source root: {traversal}"
|
|
);
|
|
|
|
let web = rpc(
|
|
&harness.rpc_base,
|
|
304,
|
|
"openhuman.memory_sources_add",
|
|
json!({
|
|
"kind": "web_page",
|
|
"label": "Worker C page",
|
|
"url": format!("{fixture_base}/page"),
|
|
"selector": "article"
|
|
}),
|
|
)
|
|
.await;
|
|
let web_id = payload(&web, "memory_sources_add web")
|
|
.pointer("/source/id")
|
|
.and_then(Value::as_str)
|
|
.expect("web source id")
|
|
.to_string();
|
|
|
|
let web_items = rpc(
|
|
&harness.rpc_base,
|
|
305,
|
|
"openhuman.memory_sources_list_items",
|
|
json!({ "source_id": web_id }),
|
|
)
|
|
.await;
|
|
let web_item_id = payload(&web_items, "memory_sources_list_items web")
|
|
.pointer("/items/0/id")
|
|
.and_then(Value::as_str)
|
|
.expect("web item id")
|
|
.to_string();
|
|
assert_eq!(web_item_id, format!("{fixture_base}/page"));
|
|
|
|
let web_read = rpc(
|
|
&harness.rpc_base,
|
|
306,
|
|
"openhuman.memory_sources_read_item",
|
|
json!({ "source_id": web_id, "item_id": web_item_id }),
|
|
)
|
|
.await;
|
|
let web_content = payload(&web_read, "memory_sources_read_item web")
|
|
.get("content")
|
|
.expect("web content");
|
|
assert_eq!(
|
|
web_content.get("title").and_then(Value::as_str),
|
|
Some("Worker C page")
|
|
);
|
|
let web_body = web_content
|
|
.get("body")
|
|
.and_then(Value::as_str)
|
|
.expect("web body");
|
|
assert!(web_body.contains("Selected coverage article"));
|
|
assert!(
|
|
!web_body.contains("Navigation text"),
|
|
"selector extraction should not include nav text: {web_body}"
|
|
);
|
|
|
|
let rss = rpc(
|
|
&harness.rpc_base,
|
|
307,
|
|
"openhuman.memory_sources_add",
|
|
json!({
|
|
"kind": "rss_feed",
|
|
"label": "Worker C feed",
|
|
"url": format!("{fixture_base}/feed"),
|
|
"max_items": 1
|
|
}),
|
|
)
|
|
.await;
|
|
let rss_id = payload(&rss, "memory_sources_add rss")
|
|
.pointer("/source/id")
|
|
.and_then(Value::as_str)
|
|
.expect("rss source id")
|
|
.to_string();
|
|
|
|
let rss_items = rpc(
|
|
&harness.rpc_base,
|
|
308,
|
|
"openhuman.memory_sources_list_items",
|
|
json!({ "source_id": rss_id }),
|
|
)
|
|
.await;
|
|
let rss_items_payload = payload(&rss_items, "memory_sources_list_items rss")
|
|
.get("items")
|
|
.and_then(Value::as_array)
|
|
.expect("rss items");
|
|
assert_eq!(
|
|
rss_items_payload.len(),
|
|
1,
|
|
"max_items should limit RSS list"
|
|
);
|
|
assert_eq!(
|
|
rss_items_payload[0].get("id").and_then(Value::as_str),
|
|
Some("rss-worker-c-1")
|
|
);
|
|
|
|
let rss_read = rpc(
|
|
&harness.rpc_base,
|
|
309,
|
|
"openhuman.memory_sources_read_item",
|
|
json!({ "source_id": rss_id, "item_id": "rss-worker-c-1" }),
|
|
)
|
|
.await;
|
|
let rss_content = payload(&rss_read, "memory_sources_read_item rss")
|
|
.get("content")
|
|
.expect("rss content");
|
|
assert_eq!(
|
|
rss_content.get("title").and_then(Value::as_str),
|
|
Some("RSS first item")
|
|
);
|
|
assert!(rss_content
|
|
.get("body")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.contains("RSS body & decoded entity"));
|
|
assert_eq!(
|
|
rss_content
|
|
.pointer("/metadata/link")
|
|
.and_then(Value::as_str),
|
|
Some("https://example.test/rss/1")
|
|
);
|
|
|
|
let sync = rpc(
|
|
&harness.rpc_base,
|
|
310,
|
|
"openhuman.memory_sources_sync",
|
|
json!({ "source_id": rss_id }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&sync, "memory_sources_sync rss")
|
|
.get("requested")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
fixture_join.abort();
|
|
}
|