mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: zahica1234 <i.milev001@gmail.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
579 lines
20 KiB
Rust
579 lines
20 KiB
Rust
//! E2E tests for the tree summarizer engine.
|
||
//!
|
||
//! Calls `engine::run_summarization` directly with a mock LLM provider so the
|
||
//! full ingest → summarize → propagate chain is exercised without needing a
|
||
//! running Ollama process. Three scenarios are covered:
|
||
//!
|
||
//! 1. `builds_hour_day_month_year_chain` — ingest chunks across two distinct
|
||
//! hours, run the summarizer, and assert the full hour→day→month→year→root
|
||
//! node chain is written.
|
||
//!
|
||
//! 2. `merges_into_existing_hour_node` — run the summarizer twice for the
|
||
//! same hour and confirm `created_at` is preserved while `updated_at`
|
||
//! advances and the summary reflects both passes.
|
||
//!
|
||
//! 3. `survives_llm_error_with_partial_progress` — program the mock so the
|
||
//! second LLM call returns an error; assert the first hour node was
|
||
//! written, the second was not, and the engine surfaces the error without
|
||
//! panicking.
|
||
//!
|
||
//! Run with: `bash scripts/test-rust-with-mock.sh --test memory_tree_summarizer_e2e`
|
||
//!
|
||
//! The mock HTTP server is started by `scripts/test-rust-with-mock.sh` and its
|
||
//! URL is available in `BACKEND_URL` / `MOCK_API_PORT`.
|
||
|
||
use std::path::Path;
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
use std::time::Duration;
|
||
|
||
use async_trait::async_trait;
|
||
use chrono::{DateTime, TimeZone, Utc};
|
||
use tempfile::tempdir;
|
||
|
||
use openhuman_core::openhuman::config::Config;
|
||
use openhuman_core::openhuman::inference::provider::traits::Provider;
|
||
use openhuman_core::openhuman::memory_tree::tree_runtime::{engine, store};
|
||
|
||
// ── Env isolation ─────────────────────────────────────────────────────────
|
||
|
||
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();
|
||
// SAFETY: guarded by ENV_LOCK which serialises process-global env mutations.
|
||
unsafe { std::env::set_var(key, path.as_os_str()) };
|
||
Self { key, old }
|
||
}
|
||
}
|
||
|
||
impl Drop for EnvVarGuard {
|
||
fn drop(&mut self) {
|
||
match &self.old {
|
||
// SAFETY: symmetric teardown under the same ENV_LOCK guard.
|
||
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
||
None => unsafe { std::env::remove_var(self.key) },
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Serialise tests: `HOME` and `OPENHUMAN_WORKSPACE` are process-global.
|
||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||
|
||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||
let m = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
||
match m.lock() {
|
||
Ok(g) => g,
|
||
Err(p) => p.into_inner(),
|
||
}
|
||
}
|
||
|
||
// ── Mock provider helpers ─────────────────────────────────────────────────
|
||
|
||
/// A provider whose `chat_with_system` returns scripted responses in order.
|
||
/// Thread-safe via a `Mutex<VecDeque>`. Each pop returns the next scripted
|
||
/// response; once the queue is exhausted, every subsequent call returns an
|
||
/// error so missing a setup step is caught immediately.
|
||
struct ScriptedProvider {
|
||
responses: Arc<Mutex<std::collections::VecDeque<Result<String, String>>>>,
|
||
call_count: Arc<Mutex<usize>>,
|
||
}
|
||
|
||
impl ScriptedProvider {
|
||
fn new(responses: Vec<Result<String, String>>) -> Self {
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] ScriptedProvider created with {} responses",
|
||
responses.len()
|
||
);
|
||
Self {
|
||
responses: Arc::new(Mutex::new(responses.into())),
|
||
call_count: Arc::new(Mutex::new(0)),
|
||
}
|
||
}
|
||
|
||
fn call_count(&self) -> usize {
|
||
*self.call_count.lock().expect("call_count lock")
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl Provider for ScriptedProvider {
|
||
async fn chat_with_system(
|
||
&self,
|
||
system_prompt: Option<&str>,
|
||
message: &str,
|
||
model: &str,
|
||
_temperature: f64,
|
||
) -> anyhow::Result<String> {
|
||
let mut count = self.call_count.lock().expect("call_count lock");
|
||
*count += 1;
|
||
let call_n = *count;
|
||
drop(count);
|
||
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] ScriptedProvider.chat_with_system call #{call_n}: \
|
||
model={model} system_prompt_len={} msg_len={}",
|
||
system_prompt.map(|s| s.len()).unwrap_or(0),
|
||
message.len()
|
||
);
|
||
|
||
let mut q = self.responses.lock().expect("responses lock");
|
||
match q.pop_front() {
|
||
Some(Ok(text)) => {
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] call #{call_n} → scripted Ok ({} chars)",
|
||
text.len()
|
||
);
|
||
Ok(text)
|
||
}
|
||
Some(Err(msg)) => {
|
||
log::debug!("[memory_tree_summarizer_e2e] call #{call_n} → scripted Err: {msg}");
|
||
Err(anyhow::anyhow!("{msg}"))
|
||
}
|
||
None => {
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] call #{call_n} → queue exhausted (fallback error)"
|
||
);
|
||
Err(anyhow::anyhow!(
|
||
"ScriptedProvider queue exhausted at call #{call_n}"
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Config builder ────────────────────────────────────────────────────────
|
||
|
||
/// Build a minimal `Config` rooted at `workspace_path`.
|
||
/// `local_ai.runtime_enabled` is irrelevant because we bypass `create_provider`
|
||
/// and pass our own `ScriptedProvider` directly.
|
||
fn build_config(workspace_path: &Path) -> Config {
|
||
Config {
|
||
workspace_dir: workspace_path.to_path_buf(),
|
||
..Config::default()
|
||
}
|
||
}
|
||
|
||
/// Return a fixed test timestamp anchored to 2026-03-15T14:xx UTC.
|
||
/// We use explicit timestamps so buffer filenames are deterministic and
|
||
/// the hour_id derived from them matches our assertions.
|
||
fn ts_hour14() -> DateTime<Utc> {
|
||
Utc.with_ymd_and_hms(2026, 3, 15, 14, 5, 0)
|
||
.single()
|
||
.expect("valid ts_hour14")
|
||
}
|
||
|
||
fn ts_hour15() -> DateTime<Utc> {
|
||
Utc.with_ymd_and_hms(2026, 3, 15, 15, 10, 0)
|
||
.single()
|
||
.expect("valid ts_hour15")
|
||
}
|
||
|
||
const NS: &str = "e2e-summarizer-test";
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||
|
||
/// Ingest content for two distinct hours, run the summarizer, and assert the
|
||
/// full chain of nodes (hour × 2, day, month, year, root) is written.
|
||
/// The mock provider returns per-hour summaries short enough that upper levels
|
||
/// fit within their token budgets without an additional LLM call — only the
|
||
/// two hour-leaf summarizations trigger LLM calls.
|
||
#[tokio::test]
|
||
async fn builds_hour_day_month_year_chain() {
|
||
let _lock = env_lock();
|
||
let tmp = tempdir().expect("tempdir");
|
||
let workspace = tmp.path().join("ws");
|
||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||
|
||
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
|
||
let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] builds_hour_day_month_year_chain: start");
|
||
|
||
let config = build_config(&workspace);
|
||
|
||
// Ingest 3 chunks: 2 for hour-14, 1 for hour-15.
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Slack: discussed deployment timeline with team",
|
||
&ts_hour14(),
|
||
None,
|
||
)
|
||
.expect("buffer_write hour14 chunk1");
|
||
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Slack: follow-up on deployment blockers",
|
||
&ts_hour14(),
|
||
None,
|
||
)
|
||
.expect("buffer_write hour14 chunk2");
|
||
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Reviewed PR for infrastructure changes",
|
||
&ts_hour15(),
|
||
None,
|
||
)
|
||
.expect("buffer_write hour15 chunk1");
|
||
|
||
// Provider: 2 LLM calls expected — one per hour leaf.
|
||
// The hour summaries are short enough that day/month/year/root fit within
|
||
// token budget and do NOT trigger additional LLM calls (propagate_node
|
||
// short-circuits when combined children text fits the level budget).
|
||
let provider = ScriptedProvider::new(vec![
|
||
Ok("User discussed deployment timeline".to_string()),
|
||
Ok("Reviewed infrastructure PR".to_string()),
|
||
]);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] running summarization");
|
||
let result = engine::run_summarization(&config, &provider, "test-model", NS, Utc::now()).await;
|
||
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] run_summarization returned: {:?}",
|
||
result
|
||
.as_ref()
|
||
.map(|n| n.as_ref().map(|node| &node.node_id))
|
||
);
|
||
assert!(
|
||
result.is_ok(),
|
||
"run_summarization should succeed: {:?}",
|
||
result
|
||
);
|
||
let last_node = result.unwrap();
|
||
assert!(last_node.is_some(), "should return a last hour node");
|
||
let last_node = last_node.unwrap();
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] last hour node: {} level={:?}",
|
||
last_node.node_id,
|
||
last_node.level
|
||
);
|
||
|
||
// Assert both hour leaves exist.
|
||
let hour14_id = "2026/03/15/14";
|
||
let hour15_id = "2026/03/15/15";
|
||
|
||
let node14 = store::read_node(&config, NS, hour14_id)
|
||
.expect("read_node hour14")
|
||
.expect("hour14 node must exist");
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] hour14 summary: {}",
|
||
node14.summary
|
||
);
|
||
assert!(
|
||
node14.summary.contains("deployment"),
|
||
"hour14 summary should contain 'deployment', got: {}",
|
||
node14.summary
|
||
);
|
||
|
||
let node15 = store::read_node(&config, NS, hour15_id)
|
||
.expect("read_node hour15")
|
||
.expect("hour15 node must exist");
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] hour15 summary: {}",
|
||
node15.summary
|
||
);
|
||
assert!(
|
||
node15.summary.contains("infrastructure") || node15.summary.contains("PR"),
|
||
"hour15 summary should contain 'infrastructure' or 'PR', got: {}",
|
||
node15.summary
|
||
);
|
||
|
||
// Assert day node was propagated.
|
||
let day_id = "2026/03/15";
|
||
let day_node = store::read_node(&config, NS, day_id)
|
||
.expect("read_node day")
|
||
.expect("day node must exist after propagation");
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] day node summary len={}",
|
||
day_node.summary.len()
|
||
);
|
||
assert!(
|
||
!day_node.summary.is_empty(),
|
||
"day summary should not be empty"
|
||
);
|
||
|
||
// Assert month node.
|
||
let month_id = "2026/03";
|
||
let month_node = store::read_node(&config, NS, month_id)
|
||
.expect("read_node month")
|
||
.expect("month node must exist after propagation");
|
||
assert!(
|
||
!month_node.summary.is_empty(),
|
||
"month summary should not be empty"
|
||
);
|
||
|
||
// Assert year node.
|
||
let year_id = "2026";
|
||
let year_node = store::read_node(&config, NS, year_id)
|
||
.expect("read_node year")
|
||
.expect("year node must exist after propagation");
|
||
assert!(
|
||
!year_node.summary.is_empty(),
|
||
"year summary should not be empty"
|
||
);
|
||
|
||
// Assert root node.
|
||
let root_node = store::read_node(&config, NS, "root")
|
||
.expect("read_node root")
|
||
.expect("root node must exist after propagation");
|
||
assert!(
|
||
!root_node.summary.is_empty(),
|
||
"root summary should not be empty"
|
||
);
|
||
|
||
// Exactly 2 LLM calls: one per hour leaf.
|
||
assert_eq!(
|
||
provider.call_count(),
|
||
2,
|
||
"expected exactly 2 LLM calls (one per hour leaf)"
|
||
);
|
||
|
||
// Buffer should be drained after successful summarization.
|
||
let remaining = store::buffer_read(&config, NS).expect("buffer_read post-run");
|
||
assert!(
|
||
remaining.is_empty(),
|
||
"buffer should be empty after successful run, got {} entries",
|
||
remaining.len()
|
||
);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] builds_hour_day_month_year_chain: PASS");
|
||
}
|
||
|
||
/// Run the summarizer twice for the same hour. Verify:
|
||
/// - `created_at` is preserved from the first run.
|
||
/// - `updated_at` is strictly greater after the second run.
|
||
/// - The merged summary contains keywords from both passes.
|
||
#[tokio::test]
|
||
async fn merges_into_existing_hour_node() {
|
||
let _lock = env_lock();
|
||
let tmp = tempdir().expect("tempdir");
|
||
let workspace = tmp.path().join("ws");
|
||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||
|
||
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
|
||
let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] merges_into_existing_hour_node: start");
|
||
|
||
let config = build_config(&workspace);
|
||
|
||
// --- First run: ingest and summarize hour-14. ---
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Discussed deployment timeline on slack",
|
||
&ts_hour14(),
|
||
None,
|
||
)
|
||
.expect("buffer_write pass1");
|
||
|
||
let provider1 = ScriptedProvider::new(vec![Ok(
|
||
"First-run summary: deployment timeline discussed".to_string(),
|
||
)]);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] first run");
|
||
let r1 = engine::run_summarization(&config, &provider1, "test-model", NS, Utc::now())
|
||
.await
|
||
.expect("first run_summarization");
|
||
assert!(r1.is_some(), "first run should yield a node");
|
||
|
||
let hour14_id = "2026/03/15/14";
|
||
let node_after_first = store::read_node(&config, NS, hour14_id)
|
||
.expect("read node after first run")
|
||
.expect("hour14 must exist after first run");
|
||
|
||
let created_at_first = node_after_first.created_at;
|
||
let updated_at_first = node_after_first.updated_at;
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] after first run: created_at={} updated_at={}",
|
||
created_at_first,
|
||
updated_at_first
|
||
);
|
||
|
||
// Small sleep so the second updated_at is strictly greater.
|
||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||
|
||
// --- Second run: ingest more content for the same hour-14. ---
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Follow-up on deployment blockers",
|
||
&ts_hour14(),
|
||
None,
|
||
)
|
||
.expect("buffer_write pass2");
|
||
|
||
let provider2 = ScriptedProvider::new(vec![Ok(
|
||
"Merged summary: deployment timeline and blockers".to_string(),
|
||
)]);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] second run (same hour)");
|
||
let r2 = engine::run_summarization(&config, &provider2, "test-model", NS, Utc::now())
|
||
.await
|
||
.expect("second run_summarization");
|
||
assert!(r2.is_some(), "second run should yield a node");
|
||
|
||
let node_after_second = store::read_node(&config, NS, hour14_id)
|
||
.expect("read node after second run")
|
||
.expect("hour14 must exist after second run");
|
||
|
||
let created_at_second = node_after_second.created_at;
|
||
let updated_at_second = node_after_second.updated_at;
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] after second run: created_at={} updated_at={}",
|
||
created_at_second,
|
||
updated_at_second
|
||
);
|
||
|
||
// `created_at` must be preserved.
|
||
assert_eq!(
|
||
created_at_first, created_at_second,
|
||
"created_at must be preserved across merges"
|
||
);
|
||
|
||
// `updated_at` must advance (or at worst stay equal if clocks are too coarse).
|
||
assert!(
|
||
updated_at_second >= updated_at_first,
|
||
"updated_at must not go backward: first={updated_at_first} second={updated_at_second}"
|
||
);
|
||
|
||
// Summary must reflect the merge (the scripted response contains "blockers").
|
||
assert!(
|
||
node_after_second.summary.contains("blockers")
|
||
|| node_after_second.summary.contains("Merged"),
|
||
"merged summary should reflect second pass content, got: {}",
|
||
node_after_second.summary
|
||
);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] merges_into_existing_hour_node: PASS");
|
||
}
|
||
|
||
/// Program the provider so the SECOND LLM call returns an error.
|
||
/// Ingest two hours' worth of content and run the summarizer.
|
||
///
|
||
/// Expected behaviour:
|
||
/// - The first hour leaf is successfully written before the error.
|
||
/// - The error propagates out of `run_summarization` as an `Err`.
|
||
/// - The process does NOT panic.
|
||
/// - Because the buffer is only deleted after ALL hour leaves are written,
|
||
/// the buffer entries are NOT deleted (the second hour's content persists).
|
||
#[tokio::test]
|
||
async fn survives_llm_error_with_partial_progress() {
|
||
let _lock = env_lock();
|
||
let tmp = tempdir().expect("tempdir");
|
||
let workspace = tmp.path().join("ws");
|
||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||
|
||
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
|
||
let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] survives_llm_error_with_partial_progress: start");
|
||
|
||
let config = build_config(&workspace);
|
||
|
||
// Ingest content for two distinct hours so two LLM calls are required.
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Hour-14 content: deployment planning",
|
||
&ts_hour14(),
|
||
None,
|
||
)
|
||
.expect("buffer_write hour14");
|
||
|
||
store::buffer_write(
|
||
&config,
|
||
NS,
|
||
"Hour-15 content: infrastructure review",
|
||
&ts_hour15(),
|
||
None,
|
||
)
|
||
.expect("buffer_write hour15");
|
||
|
||
// Provider: call 1 succeeds, call 2 returns an error.
|
||
let provider = ScriptedProvider::new(vec![
|
||
Ok("Hour-14 summary: deployment planning in progress".to_string()),
|
||
Err("boom: simulated LLM failure on second call".to_string()),
|
||
]);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] running summarization expecting partial failure");
|
||
let result = engine::run_summarization(&config, &provider, "test-model", NS, Utc::now()).await;
|
||
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] run_summarization result: is_ok={}",
|
||
result.is_ok()
|
||
);
|
||
|
||
// The engine must return an error — not panic.
|
||
assert!(
|
||
result.is_err(),
|
||
"expected Err from run_summarization when second LLM call fails, got: {:?}",
|
||
result
|
||
);
|
||
let err = result.unwrap_err();
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] propagated error (as expected): {:#}",
|
||
err
|
||
);
|
||
// Use the full anyhow error chain (alternating display) so nested context
|
||
// layers — e.g. "summarize hour leaf: LLM summarization failed: boom: …" —
|
||
// are all visible in the assertion.
|
||
let err_chain = format!("{err:#}");
|
||
assert!(
|
||
err_chain.contains("boom") || err_chain.contains("LLM summarization failed"),
|
||
"error message should mention the LLM failure, got: {err_chain}"
|
||
);
|
||
|
||
// Exactly 2 LLM calls were made.
|
||
assert_eq!(
|
||
provider.call_count(),
|
||
2,
|
||
"expected 2 LLM calls (1 success + 1 error)"
|
||
);
|
||
|
||
// The first hour leaf (hour-14) was written before the error.
|
||
// The engine writes hour leaves as it goes; the second fails before writing.
|
||
// Note: the exact behaviour depends on which hour is processed first
|
||
// (BTreeMap ordering: "2026/03/15/14" < "2026/03/15/15"), so hour-14 is first.
|
||
let hour14_id = "2026/03/15/14";
|
||
let node14 = store::read_node(&config, NS, hour14_id).expect("read_node hour14");
|
||
assert!(
|
||
node14.is_some(),
|
||
"hour-14 leaf must be written before the error on hour-15"
|
||
);
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] hour14 node present: summary={}",
|
||
node14.unwrap().summary
|
||
);
|
||
|
||
// The second hour node (hour-15) was NOT written because the LLM call failed.
|
||
let hour15_id = "2026/03/15/15";
|
||
let node15 = store::read_node(&config, NS, hour15_id).expect("read_node hour15");
|
||
assert!(
|
||
node15.is_none(),
|
||
"hour-15 leaf must NOT exist when its LLM call failed"
|
||
);
|
||
|
||
// The buffer must NOT be drained — the engine only deletes buffer entries
|
||
// after all hour leaves are successfully written. A partial failure means
|
||
// the buffer retains its entries so the next run can retry.
|
||
let remaining = store::buffer_read(&config, NS).expect("buffer_read post-error");
|
||
assert!(
|
||
!remaining.is_empty(),
|
||
"buffer must not be drained after a partial failure, but it is empty"
|
||
);
|
||
log::debug!(
|
||
"[memory_tree_summarizer_e2e] {} buffer entries remain (expected > 0)",
|
||
remaining.len()
|
||
);
|
||
|
||
log::debug!("[memory_tree_summarizer_e2e] survives_llm_error_with_partial_progress: PASS");
|
||
}
|