mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
832 lines
27 KiB
Rust
832 lines
27 KiB
Rust
//! Round 20 near-90 raw integration coverage closures.
|
|
//!
|
|
//! All fixtures are local and deterministic: temp workspaces, loopback HTTP,
|
|
//! and a fake `gh` binary. Run with `--test-threads=1`; several covered
|
|
//! surfaces resolve config/workspace through process environment.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::Duration as StdDuration;
|
|
|
|
use chrono::Utc;
|
|
use openhuman_core::openhuman::app_state::{
|
|
snapshot, update_local_state, StoredAppStatePatch, StoredOnboardingTasks,
|
|
};
|
|
use openhuman_core::openhuman::config::rpc as config_rpc;
|
|
use openhuman_core::openhuman::credentials::profiles::{
|
|
AuthProfile, AuthProfileKind, AuthProfilesStore,
|
|
};
|
|
use openhuman_core::openhuman::credentials::{
|
|
AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
|
|
};
|
|
use openhuman_core::openhuman::memory::{
|
|
ai_list_memory_files, ai_read_memory_file, ai_write_memory_file, clear_namespace,
|
|
context_query, context_recall, doc_delete, doc_list, doc_put, memory_delete_document,
|
|
memory_init, memory_list_documents, memory_list_namespaces, memory_query_namespace,
|
|
memory_recall_context, memory_recall_memories, namespace_list, ClearNamespaceParams,
|
|
DeleteDocParams, EmptyRequest, ListDocumentsRequest, ListMemoryFilesRequest, MemoryInitRequest,
|
|
PutDocParams, QueryNamespaceParams, QueryNamespaceRequest, ReadMemoryFileRequest,
|
|
RecallContextRequest, RecallMemoriesRequest, RecallNamespaceParams, WriteMemoryFileRequest,
|
|
};
|
|
use openhuman_core::openhuman::memory_sources::readers::SourceReader;
|
|
use openhuman_core::openhuman::memory_sources::sync::sync_source;
|
|
use openhuman_core::openhuman::memory_sources::{ContentType, MemorySourceEntry, SourceKind};
|
|
use openhuman_core::openhuman::threads::ops as thread_ops;
|
|
use openhuman_core::openhuman::threads::welcome_migration::migrate_welcome_agent_artifacts;
|
|
use serde_json::{json, Value};
|
|
use tempfile::{Builder, TempDir};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
static ROUND20_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set(key: &'static str, value: impl Into<String>) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, value.into()) };
|
|
Self { key, old }
|
|
}
|
|
|
|
fn set_path(key: &'static str, path: &Path) -> Self {
|
|
Self::set(key, path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
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 EnvGuard {
|
|
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 {
|
|
_tmp: TempDir,
|
|
root: PathBuf,
|
|
_guards: Vec<EnvGuard>,
|
|
}
|
|
|
|
impl Harness {
|
|
async fn config(&self) -> openhuman_core::openhuman::config::Config {
|
|
config_rpc::load_config_with_timeout()
|
|
.await
|
|
.expect("isolated config should load")
|
|
}
|
|
|
|
fn workspace_dir(&self) -> PathBuf {
|
|
self.root.join("workspace")
|
|
}
|
|
|
|
fn state_dir(&self) -> PathBuf {
|
|
self.workspace_dir().join("state")
|
|
}
|
|
|
|
fn app_state_file(&self) -> PathBuf {
|
|
self.state_dir().join("app-state.json")
|
|
}
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ROUND20_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("create target");
|
|
Builder::new()
|
|
.prefix("near90-closure-round20-")
|
|
.tempdir_in("target")
|
|
.expect("round20 tempdir")
|
|
}
|
|
|
|
fn write_min_config(root: &Path, api_url: &str) {
|
|
std::fs::create_dir_all(root).expect("create config root");
|
|
let cfg = format!(
|
|
r#"api_url = "{api_url}"
|
|
default_model = "round20-coverage-model"
|
|
default_temperature = 0.2
|
|
onboarding_completed = true
|
|
chat_onboarding_completed = false
|
|
|
|
[observability]
|
|
analytics_enabled = false
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
|
|
[meet]
|
|
auto_orchestrator_handoff = false
|
|
|
|
[local_ai]
|
|
enabled = false
|
|
runtime_enabled = false
|
|
opt_in_confirmed = false
|
|
|
|
[memory]
|
|
provider = "none"
|
|
embedding_provider = "none"
|
|
embedding_model = "none"
|
|
embedding_dimensions = 0
|
|
auto_save = false
|
|
|
|
[memory_tree]
|
|
embedding_strict = false
|
|
"#
|
|
);
|
|
std::fs::write(root.join("config.toml"), &cfg).expect("write config.toml");
|
|
let _: openhuman_core::openhuman::config::Config =
|
|
toml::from_str(&cfg).expect("round20 config must match schema");
|
|
}
|
|
|
|
fn setup(api_url: &str) -> Harness {
|
|
let tmp = tempdir();
|
|
let root = tmp.path().join("openhuman");
|
|
write_min_config(&root, api_url);
|
|
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::set("OPENHUMAN_KEYRING_BACKEND", "file"),
|
|
EnvGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
EnvGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
|
|
EnvGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
|
|
];
|
|
|
|
Harness {
|
|
_tmp: tmp,
|
|
root,
|
|
_guards: guards,
|
|
}
|
|
}
|
|
|
|
fn source_entry(id: &str, kind: SourceKind) -> MemorySourceEntry {
|
|
MemorySourceEntry {
|
|
id: id.to_string(),
|
|
kind,
|
|
label: format!("{id} label"),
|
|
enabled: true,
|
|
toolkit: None,
|
|
connection_id: None,
|
|
path: None,
|
|
glob: None,
|
|
url: None,
|
|
branch: None,
|
|
paths: Vec::new(),
|
|
query: None,
|
|
since_days: None,
|
|
max_items: None,
|
|
max_commits: None,
|
|
max_issues: None,
|
|
max_prs: None,
|
|
selector: None,
|
|
max_tokens_per_sync: None,
|
|
max_cost_per_sync_usd: None,
|
|
sync_depth_days: None,
|
|
}
|
|
}
|
|
|
|
async fn auth_me_server(
|
|
status: &'static str,
|
|
body: &'static str,
|
|
) -> (String, tokio::task::JoinHandle<()>) {
|
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("bind auth fixture");
|
|
let url = format!("http://{}", listener.local_addr().expect("listener addr"));
|
|
let task = tokio::spawn(async move {
|
|
while let Ok((mut stream, _)) = listener.accept().await {
|
|
let mut req = [0_u8; 2048];
|
|
let _ = stream.read(&mut req).await;
|
|
let response = format!(
|
|
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
}
|
|
});
|
|
(url, task)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn round20_app_state_quarantines_directory_state_and_uses_stored_user_on_http_error() {
|
|
let _lock = env_lock();
|
|
let (api_url, server) = auth_me_server("503 Service Unavailable", r#"{"error":"down"}"#).await;
|
|
let harness = setup(&api_url);
|
|
let config = harness.config().await;
|
|
|
|
AuthService::from_config(&config)
|
|
.store_provider_token(
|
|
APP_SESSION_PROVIDER,
|
|
DEFAULT_AUTH_PROFILE_NAME,
|
|
"round20.remote.token",
|
|
HashMap::from([
|
|
("user_id".to_string(), "stored-round20".to_string()),
|
|
(
|
|
"user_json".to_string(),
|
|
json!({"id":"stored-round20","fullName":"Stored Round20"}).to_string(),
|
|
),
|
|
]),
|
|
true,
|
|
)
|
|
.expect("seed app session");
|
|
|
|
let first = update_local_state(StoredAppStatePatch {
|
|
keyring_consent: None,
|
|
encryption_key: None,
|
|
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
|
accessibility_permission_granted: false,
|
|
local_model_consent_given: true,
|
|
local_model_download_started: false,
|
|
enabled_tools: vec!["github".to_string()],
|
|
connected_sources: vec!["rss".to_string()],
|
|
updated_at_ms: Some(20),
|
|
})),
|
|
})
|
|
.await
|
|
.expect("write tasks")
|
|
.value;
|
|
assert!(first.encryption_key.is_none());
|
|
assert_eq!(
|
|
first.onboarding_tasks.expect("tasks").enabled_tools,
|
|
vec!["github"]
|
|
);
|
|
|
|
let snap = snapshot().await.expect("snapshot").value;
|
|
assert_eq!(
|
|
snap.current_user.as_ref().and_then(|v| v.get("id")),
|
|
Some(&json!("stored-round20"))
|
|
);
|
|
assert_eq!(snap.session_token.as_deref(), Some("round20.remote.token"));
|
|
assert!(!snap.analytics_enabled);
|
|
assert!(!snap.meet_auto_orchestrator_handoff);
|
|
|
|
std::fs::remove_file(harness.app_state_file()).expect("remove app-state file");
|
|
std::fs::create_dir_all(harness.app_state_file()).expect("directory at app-state path");
|
|
let recovered = snapshot()
|
|
.await
|
|
.expect("snapshot should quarantine unreadable app-state path")
|
|
.value;
|
|
assert!(recovered.local_state.onboarding_tasks.is_none());
|
|
assert!(!harness.app_state_file().exists());
|
|
assert!(
|
|
std::fs::read_dir(harness.state_dir())
|
|
.expect("state dir")
|
|
.filter_map(Result::ok)
|
|
.any(|entry| entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.contains("json.corrupted")),
|
|
"directory app-state should be moved aside"
|
|
);
|
|
|
|
server.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn round20_credentials_profiles_cover_legacy_plaintext_errors_and_active_edges() {
|
|
let _lock = env_lock();
|
|
let harness = setup("http://127.0.0.1:9");
|
|
let state_dir = harness.root.join("profile-store");
|
|
let store = AuthProfilesStore::new(&state_dir, false);
|
|
|
|
let token = AuthProfile::new_token("linear", "team", "lin-round20".to_string());
|
|
store
|
|
.upsert_profile(token.clone(), false)
|
|
.expect("insert token");
|
|
let inactive = store.load().expect("load inactive token");
|
|
assert!(!inactive.active_profiles.contains_key("linear"));
|
|
store
|
|
.set_active_profile("linear", &token.id)
|
|
.expect("activate token");
|
|
assert_eq!(
|
|
store
|
|
.load()
|
|
.expect("load active")
|
|
.active_profiles
|
|
.get("linear"),
|
|
Some(&token.id)
|
|
);
|
|
|
|
let path = store.path().to_path_buf();
|
|
let now = Utc::now().to_rfc3339();
|
|
std::fs::write(
|
|
&path,
|
|
serde_json::to_string_pretty(&json!({
|
|
"schema_version": 1,
|
|
"updated_at": now,
|
|
"active_profiles": { "github": "legacy-oauth", "token": "legacy-token" },
|
|
"profiles": {
|
|
"legacy-token": {
|
|
"provider": "token",
|
|
"profile_name": "plain",
|
|
"kind": "token",
|
|
"token": "plain-secret",
|
|
"metadata": { "k": "v" },
|
|
"created_at": "bad-created",
|
|
"updated_at": "bad-updated"
|
|
},
|
|
"legacy-oauth": {
|
|
"provider": "github",
|
|
"profile_name": "oauth",
|
|
"kind": "oauth",
|
|
"access_token": "plain-access",
|
|
"refresh_token": "plain-refresh",
|
|
"id_token": "",
|
|
"expires_at": null,
|
|
"token_type": "Bearer",
|
|
"scope": "repo",
|
|
"metadata": {},
|
|
"created_at": "2026-05-29T00:00:00Z",
|
|
"updated_at": "2026-05-29T00:00:00Z"
|
|
}
|
|
}
|
|
}))
|
|
.expect("profile json"),
|
|
)
|
|
.expect("write legacy plaintext store");
|
|
|
|
let loaded = store.load().expect("load plaintext legacy profiles");
|
|
assert_eq!(
|
|
loaded
|
|
.profiles
|
|
.get("legacy-token")
|
|
.and_then(|profile| profile.token.as_deref()),
|
|
Some("plain-secret")
|
|
);
|
|
let oauth = loaded.profiles.get("legacy-oauth").expect("oauth loaded");
|
|
assert_eq!(oauth.kind, AuthProfileKind::OAuth);
|
|
assert_eq!(
|
|
oauth.token_set.as_ref().map(|tokens| (
|
|
tokens.access_token.as_str(),
|
|
tokens.refresh_token.as_deref(),
|
|
tokens.expires_at
|
|
)),
|
|
Some(("plain-access", Some("plain-refresh"), None))
|
|
);
|
|
|
|
let set_active_err = store
|
|
.set_active_profile("missing", "not-there")
|
|
.expect_err("missing active profile")
|
|
.to_string();
|
|
assert!(set_active_err.contains("Auth profile not found"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn round20_memory_sources_readers_and_sync_cover_error_edges_without_network() {
|
|
let _lock = env_lock();
|
|
let harness = setup("http://127.0.0.1:9");
|
|
let config = harness.config().await;
|
|
|
|
let rss = openhuman_core::openhuman::memory_sources::readers::rss::RssReader;
|
|
let mut missing_url = source_entry("rss-missing-url", SourceKind::RssFeed);
|
|
assert_eq!(
|
|
rss.list_items(&missing_url, &config)
|
|
.await
|
|
.expect_err("rss url required"),
|
|
"rss source requires a url"
|
|
);
|
|
|
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("bind feed fixture");
|
|
let feed_url = format!("http://{}", listener.local_addr().expect("addr"));
|
|
let server = tokio::spawn(async move {
|
|
if let Ok((mut stream, _)) = listener.accept().await {
|
|
let mut req = [0_u8; 1024];
|
|
let _ = stream.read(&mut req).await;
|
|
let response =
|
|
"HTTP/1.1 200 OK\r\ncontent-type: text/xml\r\ncontent-length: 10\r\n\r\nnot-a-feed";
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
}
|
|
});
|
|
missing_url.url = Some(feed_url);
|
|
let feed_err = rss
|
|
.list_items(&missing_url, &config)
|
|
.await
|
|
.expect_err("unrecognized feed rejected");
|
|
assert!(feed_err.contains("unrecognized feed format"));
|
|
let _ = server.await;
|
|
|
|
// GitHub reader portion requires a real `gh` on PATH to shadow with our
|
|
// fake. Skip on CI containers that lack `gh` — without it the reader
|
|
// falls through to the real GitHub API and rate-limits.
|
|
let gh_available = std::process::Command::new("gh")
|
|
.arg("--version")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
|
|
let tmp = tempdir();
|
|
let bin = tmp.path().join("bin");
|
|
std::fs::create_dir_all(&bin).expect("bin dir");
|
|
let script = bin.join("gh");
|
|
write_fake_gh_round20(&script);
|
|
let git_stub = bin.join("git");
|
|
std::fs::write(&git_stub, "#!/usr/bin/env bash\nexit 1\n").expect("write fake git");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&git_stub)
|
|
.expect("metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&git_stub, perms).expect("chmod fake git");
|
|
}
|
|
let old_path = std::env::var("PATH").unwrap_or_default();
|
|
let _path = EnvGuard::set("PATH", format!("{}:{old_path}", bin.display()));
|
|
|
|
let github = openhuman_core::openhuman::memory_sources::readers::github::GithubReader;
|
|
let mut entry = source_entry("github-round20", SourceKind::GithubRepo);
|
|
entry.url = Some("git@github.com:tinyhumansai/openhuman.git".to_string());
|
|
if !gh_available {
|
|
eprintln!("skipping github reader assertions: gh CLI not available");
|
|
} else {
|
|
let items = github
|
|
.list_items(&entry, &config)
|
|
.await
|
|
.expect("github list via fake gh");
|
|
assert!(items.iter().any(|item| item.id == "commit:def456"));
|
|
assert!(items.iter().any(|item| item.id == "issue:20"));
|
|
|
|
let pr = github
|
|
.read_item(&entry, "pr:21", &config)
|
|
.await
|
|
.expect("read merged pr");
|
|
assert_eq!(pr.content_type, ContentType::Markdown);
|
|
assert!(pr.body.contains("merged at 2026-05-29T01:00:00Z"));
|
|
assert_eq!(
|
|
pr.metadata.get("merged").and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let bad_issue = github
|
|
.read_item(&entry, "issue:not-a-number", &config)
|
|
.await
|
|
.expect_err("bad issue number");
|
|
assert!(bad_issue.contains("invalid issue number"));
|
|
}
|
|
|
|
let mut disabled = source_entry("disabled-twitter", SourceKind::TwitterQuery);
|
|
disabled.enabled = false;
|
|
let disabled_err = sync_source(disabled, config.clone())
|
|
.await
|
|
.expect_err("disabled sync rejected");
|
|
assert!(disabled_err.contains("is disabled"));
|
|
|
|
let twitter = source_entry("twitter-round20", SourceKind::TwitterQuery);
|
|
sync_source(twitter, config)
|
|
.await
|
|
.expect("twitter placeholder is reported by background task");
|
|
tokio::time::sleep(StdDuration::from_millis(25)).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn round20_memory_documents_files_and_envelopes_cover_success_and_failure_paths() {
|
|
let _lock = env_lock();
|
|
let harness = setup("http://127.0.0.1:9");
|
|
|
|
let init = memory_init(MemoryInitRequest {
|
|
jwt_token: Some("ignored-round20".to_string()),
|
|
})
|
|
.await
|
|
.expect("memory init")
|
|
.value
|
|
.data
|
|
.expect("init data");
|
|
assert!(init.initialized);
|
|
assert!(init.memory_dir.ends_with("/memory"));
|
|
|
|
ai_write_memory_file(WriteMemoryFileRequest {
|
|
relative_path: "notes/round20.md".to_string(),
|
|
content: "Round20 local file memory".to_string(),
|
|
})
|
|
.await
|
|
.expect("write memory file");
|
|
let read = ai_read_memory_file(ReadMemoryFileRequest {
|
|
relative_path: "notes/round20.md".to_string(),
|
|
})
|
|
.await
|
|
.expect("read memory file")
|
|
.value
|
|
.data
|
|
.expect("read data");
|
|
assert!(read.content.contains("local file memory"));
|
|
let traversal = ai_write_memory_file(WriteMemoryFileRequest {
|
|
relative_path: "../escape.md".to_string(),
|
|
content: "no".to_string(),
|
|
})
|
|
.await
|
|
.expect_err("traversal rejected");
|
|
assert!(traversal.contains("path traversal"));
|
|
let listed = ai_list_memory_files(ListMemoryFilesRequest {
|
|
relative_dir: "notes".to_string(),
|
|
})
|
|
.await
|
|
.expect("list notes")
|
|
.value
|
|
.data
|
|
.expect("list data");
|
|
assert_eq!(listed.files, vec!["round20.md"]);
|
|
|
|
// Use a deterministic namespace — random UUIDs can trigger PII detection.
|
|
let namespace = "round20-cov-test-ns".to_string();
|
|
let put = doc_put(PutDocParams {
|
|
namespace: namespace.clone(),
|
|
key: "launch-note".to_string(),
|
|
title: "Launch Note".to_string(),
|
|
content: "The Calypso launch depends on QA, design, and release owners.".to_string(),
|
|
source_type: "note".to_string(),
|
|
priority: "high".to_string(),
|
|
tags: vec!["round20".to_string()],
|
|
metadata: json!({"round": 20}),
|
|
category: "core".to_string(),
|
|
session_id: Some("session-round20".to_string()),
|
|
document_id: Some("doc-round20".to_string()),
|
|
})
|
|
.await
|
|
.expect("put doc")
|
|
.value;
|
|
assert_eq!(put.document_id, "doc-round20");
|
|
|
|
let docs = doc_list(None)
|
|
.await
|
|
.expect("doc list all")
|
|
.value
|
|
.get("documents")
|
|
.and_then(Value::as_array)
|
|
.cloned()
|
|
.expect("documents");
|
|
assert!(docs.iter().any(|doc| doc["documentId"] == "doc-round20"));
|
|
let namespaces = namespace_list().await.expect("namespace list").value;
|
|
assert!(namespaces.contains(&namespace));
|
|
|
|
let query = context_query(QueryNamespaceParams {
|
|
namespace: namespace.clone(),
|
|
query: "Calypso QA".to_string(),
|
|
limit: Some(3),
|
|
})
|
|
.await
|
|
.expect("context query")
|
|
.value;
|
|
assert!(query.contains("Calypso") || query.contains("launch"));
|
|
let recall = context_recall(RecallNamespaceParams {
|
|
namespace: namespace.clone(),
|
|
limit: Some(3),
|
|
})
|
|
.await
|
|
.expect("context recall")
|
|
.value;
|
|
assert!(recall.as_deref().unwrap_or_default().contains("Calypso"));
|
|
|
|
let envelope_docs = memory_list_documents(ListDocumentsRequest {
|
|
namespace: Some(namespace.clone()),
|
|
})
|
|
.await
|
|
.expect("memory list documents")
|
|
.value
|
|
.data
|
|
.expect("list documents data");
|
|
assert_eq!(envelope_docs.count, 1);
|
|
let envelope_namespaces = memory_list_namespaces(EmptyRequest {})
|
|
.await
|
|
.expect("memory namespaces")
|
|
.value
|
|
.data
|
|
.expect("namespaces data");
|
|
assert!(envelope_namespaces.namespaces.contains(&namespace));
|
|
|
|
let hidden_context = memory_query_namespace(QueryNamespaceRequest {
|
|
namespace: namespace.clone(),
|
|
query: "Calypso".to_string(),
|
|
include_references: Some(false),
|
|
document_ids: Some(vec!["doc-round20".to_string()]),
|
|
limit: Some(1),
|
|
max_chunks: Some(2),
|
|
})
|
|
.await
|
|
.expect("query namespace envelope")
|
|
.value
|
|
.data
|
|
.expect("query namespace data");
|
|
assert!(hidden_context.context.is_none());
|
|
assert!(hidden_context.llm_context_message.is_some());
|
|
|
|
let recall_context = memory_recall_context(RecallContextRequest {
|
|
namespace: namespace.clone(),
|
|
include_references: Some(true),
|
|
limit: None,
|
|
max_chunks: Some(1),
|
|
})
|
|
.await
|
|
.expect("recall context envelope")
|
|
.value
|
|
.data
|
|
.expect("recall context data");
|
|
assert!(recall_context.llm_context_message.is_some());
|
|
|
|
let memories = memory_recall_memories(RecallMemoriesRequest {
|
|
namespace: namespace.clone(),
|
|
min_retention: Some(0.0),
|
|
as_of: Some(0.0),
|
|
limit: Some(1),
|
|
max_chunks: None,
|
|
top_k: Some(1),
|
|
})
|
|
.await
|
|
.expect("recall memories")
|
|
.value
|
|
.data
|
|
.expect("memories data");
|
|
assert!(!memories.memories.is_empty());
|
|
|
|
let deleted =
|
|
memory_delete_document(openhuman_core::openhuman::memory::DeleteDocumentRequest {
|
|
namespace: namespace.clone(),
|
|
document_id: "doc-round20".to_string(),
|
|
})
|
|
.await
|
|
.expect("delete document envelope")
|
|
.value
|
|
.data
|
|
.expect("delete data");
|
|
assert!(deleted.deleted);
|
|
let second_delete = doc_delete(DeleteDocParams {
|
|
namespace: namespace.clone(),
|
|
document_id: "doc-round20".to_string(),
|
|
})
|
|
.await
|
|
.expect("direct delete missing")
|
|
.value;
|
|
assert_eq!(second_delete["deleted"], false);
|
|
let cleared = clear_namespace(ClearNamespaceParams { namespace })
|
|
.await
|
|
.expect("clear namespace")
|
|
.value;
|
|
assert!(cleared.cleared);
|
|
|
|
let _ = harness;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn round20_threads_fallback_title_delete_missing_and_welcome_noop_paths() {
|
|
let _lock = env_lock();
|
|
let harness = setup("http://127.0.0.1:9");
|
|
|
|
let created = thread_ops::thread_create_new(
|
|
openhuman_core::openhuman::memory::CreateConversationThreadRequest {
|
|
labels: None,
|
|
personality_id: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create thread")
|
|
.value
|
|
.data
|
|
.expect("created data");
|
|
|
|
let user_message = openhuman_core::openhuman::memory::ConversationMessageRecord {
|
|
id: "round20-user-msg".to_string(),
|
|
content: "Please map the onboarding telemetry rollout across product analytics and QA."
|
|
.to_string(),
|
|
message_type: "text".to_string(),
|
|
extra_metadata: Value::Null,
|
|
sender: "user".to_string(),
|
|
created_at: Utc::now().to_rfc3339(),
|
|
};
|
|
thread_ops::message_append(
|
|
openhuman_core::openhuman::memory::AppendConversationMessageRequest {
|
|
thread_id: created.id.clone(),
|
|
message: user_message,
|
|
},
|
|
)
|
|
.await
|
|
.expect("append user");
|
|
|
|
let fallback = thread_ops::thread_generate_title(
|
|
openhuman_core::openhuman::memory::GenerateConversationThreadTitleRequest {
|
|
thread_id: created.id.clone(),
|
|
assistant_message: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("fallback title")
|
|
.value
|
|
.data
|
|
.expect("fallback data");
|
|
assert!(!fallback.title.trim().is_empty());
|
|
assert_ne!(fallback.title, created.title);
|
|
|
|
let missing_update = thread_ops::message_update(
|
|
openhuman_core::openhuman::memory::UpdateConversationMessageRequest {
|
|
thread_id: created.id.clone(),
|
|
message_id: "missing-message".to_string(),
|
|
extra_metadata: Some(json!({"x": true})),
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("missing message update fails");
|
|
assert!(missing_update.contains("message") || missing_update.contains("not found"));
|
|
|
|
let missing_delete = thread_ops::thread_delete(
|
|
openhuman_core::openhuman::memory::DeleteConversationThreadRequest {
|
|
thread_id: "missing-thread-round20".to_string(),
|
|
deleted_at: Utc::now().to_rfc3339(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("missing thread delete is idempotent")
|
|
.value
|
|
.data
|
|
.expect("delete data");
|
|
assert!(!missing_delete.deleted);
|
|
|
|
std::fs::create_dir_all(harness.workspace_dir().join("session_raw")).expect("raw dir");
|
|
std::fs::write(
|
|
harness.workspace_dir().join("session_raw/ignore.jsonl"),
|
|
"{\"_meta\":{\"agent\":\"orchestrator\",\"thread_id\":\"t\"}}\n",
|
|
)
|
|
.expect("write non-welcome transcript");
|
|
let migration =
|
|
migrate_welcome_agent_artifacts(&harness.workspace_dir()).expect("noop migration");
|
|
assert_eq!(migration.threads_updated, 0);
|
|
assert_eq!(migration.transcripts_updated, 0);
|
|
}
|
|
|
|
fn write_fake_gh_round20(path: &PathBuf) {
|
|
let script = r#"#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
if [[ "${1:-}" == "--version" ]]; then
|
|
echo "gh version 2.0.0"
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" != "api" ]]; then
|
|
echo "unsupported gh command" >&2
|
|
exit 2
|
|
fi
|
|
case "${2:-}" in
|
|
repos/tinyhumansai/openhuman/commits\?*)
|
|
cat <<'JSON'
|
|
[{"sha":"def456","commit":{"message":"Round20 commit fixture","author":{"name":"Ada","email":"ada@example.test","date":"2026-05-29T00:00:00Z"},"committer":{"name":"Ada","email":"ada@example.test","date":"2026-05-29T00:00:00Z"}}}]
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/issues\?*)
|
|
cat <<'JSON'
|
|
[{"number":20,"title":"Round20 issue","body":null,"state":"closed","user":null,"labels":[],"created_at":null,"updated_at":"2026-05-29T00:30:00Z","pull_request":null}]
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/pulls\?*)
|
|
cat <<'JSON'
|
|
[{"number":21,"title":"Round20 merged PR","body":null,"state":"closed","user":null,"labels":[],"created_at":null,"updated_at":"2026-05-29T01:00:00Z","merged_at":"2026-05-29T01:00:00Z","comments":0}]
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/pulls/21)
|
|
cat <<'JSON'
|
|
{"number":21,"title":"Round20 merged PR","body":null,"state":"closed","user":null,"labels":[],"created_at":null,"updated_at":"2026-05-29T01:00:00Z","merged_at":"2026-05-29T01:00:00Z","comments":0}
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/issues/21/comments\?*)
|
|
cat <<'JSON'
|
|
[]
|
|
JSON
|
|
;;
|
|
*)
|
|
echo "unexpected gh api path: ${2:-}" >&2
|
|
exit 3
|
|
;;
|
|
esac
|
|
"#;
|
|
std::fs::write(path, script).expect("write fake gh");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(path)
|
|
.expect("fake gh metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(path, perms).expect("chmod fake gh");
|
|
}
|
|
}
|