Files
openhuman/tests/memory_tree_summarizer_e2e.rs
T

579 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}