feat(memory): deterministic E2GraphRAG retrieval (replace agentic walk) (#3947)

This commit is contained in:
Steven Enamakel
2026-06-23 00:53:10 -07:00
committed by GitHub
parent f435d3af7f
commit 8ee89acdaf
38 changed files with 2089 additions and 3999 deletions
+131
View File
@@ -0,0 +1,131 @@
//! E2E tests for the deterministic E2GraphRAG retriever (`fast_retrieve`).
//!
//! These replace the old agentic `memory_tree_walk_e2e.rs`. There is no LLM in
//! the retrieval loop, so no mock server is needed — we ingest a small chat
//! corpus, then assert that:
//! - an entity-relationship query routes to the *local* branch and returns
//! the chunk where the two entities co-occur, ranked by entity coverage;
//! - a query with no extractable entities routes to the *global* branch and
//! returns cleanly (no panic) over the same store;
//! - the output is structured `QueryResponse` evidence (hits), not prose.
//!
//! spaCy is disabled here so the run is deterministic and Python-free in CI —
//! query-entity extraction uses the regex fallback (emails/handles/hashtags),
//! which is enough to exercise both routing branches.
//!
//! Run with:
//! cargo test --test memory_fast_retrieve_e2e
//! or via the project wrapper:
//! bash scripts/test-rust-with-mock.sh --test memory_fast_retrieve_e2e
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::ingest_pipeline::ingest_chat;
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use openhuman_core::openhuman::memory_tree::retrieval::{fast_retrieve, FastRetrieveOptions};
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
// Inert embedder — no Ollama/cloud in CI.
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
// Deterministic, Python-free entity extraction (regex fallback).
cfg.memory_tree.spacy_enabled = false;
(tmp, cfg)
}
async fn seed_chat(cfg: &Config, source: &str, text: &str) {
let batch = ChatBatch {
platform: "slack".into(),
channel_label: source.into(),
messages: vec![ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: text.into(),
source_ref: Some("slack://x".into()),
}],
};
ingest_chat(cfg, source, "alice", vec![], batch)
.await
.expect("ingest_chat should succeed");
}
#[tokio::test]
async fn local_branch_returns_cooccurring_evidence() {
let (_tmp, cfg) = test_config();
// alice + bob co-occur in one message → graph edge + both indexed on the
// same leaf chunk.
seed_chat(
&cfg,
"slack:#eng",
"Sync between alice@example.com and bob@example.com on the runbook.",
)
.await;
// An unrelated message that should NOT surface for the alice+bob query.
seed_chat(
&cfg,
"slack:#random",
"Lunch plans for friday with the team.",
)
.await;
let resp = fast_retrieve(
&cfg,
"what did alice@example.com and bob@example.com discuss",
FastRetrieveOptions::default(),
)
.await
.expect("fast_retrieve should succeed");
assert!(
!resp.hits.is_empty(),
"co-occurring entities should yield a local hit; got {resp:?}"
);
// Coverage score = both query entities matched the same node.
assert!(
resp.hits.iter().any(|h| h.score >= 2.0),
"top local hit should have entity-coverage score >= 2; got {:?}",
resp.hits.iter().map(|h| h.score).collect::<Vec<_>>()
);
// Structured evidence, not prose — every hit has a node id + content.
assert!(resp.hits.iter().all(|h| !h.node_id.is_empty()));
}
#[tokio::test]
async fn global_branch_handles_entity_free_query() {
let (_tmp, cfg) = test_config();
seed_chat(
&cfg,
"slack:#eng",
"Sync between alice@example.com and bob@example.com on the runbook.",
)
.await;
// No mechanical entities in the query → global/dense branch. With the inert
// embedder this returns recency-ordered summaries (possibly empty), and
// crucially must not panic or error.
let resp = fast_retrieve(
&cfg,
"give me a recap of everything important",
FastRetrieveOptions::default(),
)
.await
.expect("global branch should succeed");
// total/truncated are well-formed regardless of hit count.
assert_eq!(resp.truncated, resp.total > resp.hits.len());
}
#[tokio::test]
async fn empty_store_returns_no_hits() {
let (_tmp, cfg) = test_config();
let resp = fast_retrieve(&cfg, "anything at all", FastRetrieveOptions::default())
.await
.expect("retrieval over empty store should succeed");
assert!(resp.hits.is_empty());
assert_eq!(resp.total, 0);
}
-2
View File
@@ -24,7 +24,6 @@ use openhuman_core::openhuman::embeddings::NoopEmbedding;
use openhuman_core::openhuman::memory::query::{
MemoryQueryTool, MemoryTreeDrillDownTool, MemoryTreeFetchLeavesTool,
MemoryTreeIngestDocumentTool, MemoryTreeQuerySourceTool, MemoryTreeSearchEntitiesTool,
MemoryTreeWalkTool,
};
use openhuman_core::openhuman::memory::tools::{
MemoryForgetTool, MemoryRecallTool, MemoryStoreTool,
@@ -1060,7 +1059,6 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() {
&MemoryTreeDrillDownTool,
&MemoryTreeFetchLeavesTool,
&MemoryTreeIngestDocumentTool,
&MemoryTreeWalkTool,
] {
assert!(!tool.name().is_empty());
assert!(!tool.description().is_empty());
+1 -116
View File
@@ -16,9 +16,6 @@ use tempfile::TempDir;
use openhuman_core::core::event_bus::{DomainEvent, EventHandler};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::inference::provider::traits::{ChatMessage, Provider};
use openhuman_core::openhuman::memory::query::{
run_walk, MemoryTreeWalkTool, WalkOptions, WalkStopReason,
};
use openhuman_core::openhuman::memory_store::chunks::store::upsert_chunks;
use openhuman_core::openhuman::memory_store::chunks::types::{
approx_token_count, chunk_id, Chunk, Metadata, SourceKind as ChunkSourceKind, SourceRef,
@@ -45,10 +42,8 @@ use openhuman_core::openhuman::memory_tree::tree::{
append_leaf_deferred, get_or_create_tree, store as tree_store, LabelStrategy, LeafRef,
};
use openhuman_core::openhuman::memory_tree::tree_runtime::{
derive_parent_id, engine, estimate_tokens, level_from_node_id, rpc as tree_runtime_rpc,
store as runtime_store, TreeNode,
engine, rpc as tree_runtime_rpc, store as runtime_store,
};
use openhuman_core::openhuman::tools::traits::Tool;
struct EnvVarGuard {
key: &'static str,
@@ -102,22 +97,6 @@ fn config_in(tmp: &TempDir) -> Config {
cfg
}
fn runtime_node(namespace: &str, node_id: &str, summary: &str) -> TreeNode {
let ts = Utc.with_ymd_and_hms(2026, 5, 29, 13, 45, 0).unwrap();
TreeNode {
node_id: node_id.to_string(),
namespace: namespace.to_string(),
level: level_from_node_id(node_id),
parent_id: derive_parent_id(node_id),
summary: summary.to_string(),
token_count: estimate_tokens(summary),
child_count: 0,
created_at: ts,
updated_at: ts,
metadata: None,
}
}
fn staged_chunk(cfg: &Config, source_id: &str, seq: u32, tokens: u32) -> Chunk {
let ts = Utc
.timestamp_millis_opt(1_700_000_000_000 + seq as i64)
@@ -280,34 +259,6 @@ async fn tree_runtime_engine_rpc_and_walk_cover_success_and_edge_paths() {
.expect("rebuild tree");
assert_eq!(rebuilt.total_nodes, 6);
assert_eq!(runtime_store::buffer_read(&cfg, ns).unwrap().len(), 1);
let walk_provider = ScriptedProvider::new([
r#"Surveying children <tool_call>{"name":"peek","arguments":{"node_ids":["2026","missing"]}}</tool_call>"#,
r#"<tool_call>{"name":"descend","arguments":{"node_id":"2026/05/29"}}</tool_call>"#,
r#"<tool_call>{"name":"fetch_leaves","arguments":{"node_id":"2026/05/29"}}</tool_call>"#,
r#"<tool_call>{"name":"answer","arguments":{"text":"Alice and Bob discussed launch cleanup."}}</tool_call>"#,
]);
let outcome = run_walk(
&cfg,
&walk_provider,
"Who discussed launch cleanup?",
WalkOptions {
max_turns: 8,
start_node_id: None,
namespace: ns.into(),
model: Some("scripted".into()),
},
)
.await
.expect("walk");
assert_eq!(outcome.stopped_reason, WalkStopReason::Answered);
assert_eq!(outcome.turns_used, 4);
assert!(outcome.answer.contains("Alice and Bob"));
let tool = MemoryTreeWalkTool;
assert_eq!(tool.name(), "memory_tree_walk");
let missing_query = tool.execute(json!({ "namespace": ns })).await.unwrap_err();
assert!(missing_query.to_string().contains("`query` is required"));
}
#[tokio::test]
@@ -377,72 +328,6 @@ async fn bucket_seal_deferred_and_fallback_paths_preserve_buffers_and_labels() {
assert_eq!(parent.item_ids, sealed);
}
#[tokio::test]
async fn memory_walk_provider_errors_and_unknown_actions_are_reported() {
let tmp = TempDir::new().expect("tempdir");
let cfg = config_in(&tmp);
let ns = "walk-round14";
for node in [
runtime_node(ns, "root", "root has one 2026 child"),
runtime_node(ns, "2026", "year node"),
] {
runtime_store::write_node(&cfg, &node).expect("write node");
}
let provider = ScriptedProvider::new([
r#"<tool_call>{"name":"dance","arguments":{"node_id":"2026"}}</tool_call>"#,
"",
]);
let outcome = run_walk(
&cfg,
&provider,
"exercise unknown action",
WalkOptions {
max_turns: 2,
start_node_id: Some("root".into()),
namespace: ns.into(),
model: Some("scripted".into()),
},
)
.await
.expect("unknown action walk");
assert_eq!(outcome.stopped_reason, WalkStopReason::LlmGaveUp);
assert!(outcome.trace[0]
.result_preview
.contains("unknown walk action"));
struct FailingProvider;
#[async_trait]
impl Provider for FailingProvider {
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
let _ = (system_prompt, message, model, temperature);
anyhow::bail!("scripted provider failure")
}
}
let failed = run_walk(
&cfg,
&FailingProvider,
"force provider error",
WalkOptions {
max_turns: 1,
start_node_id: Some("missing".into()),
namespace: ns.into(),
model: None,
},
)
.await
.expect("provider errors become walk outcome");
assert!(matches!(failed.stopped_reason, WalkStopReason::Error(_)));
assert!(failed.answer.contains("Walk failed"));
}
#[tokio::test]
async fn composio_providers_sync_state_and_bus_surfaces_cover_read_write_edges() {
let _lock = env_lock();
-535
View File
@@ -1,535 +0,0 @@
//! E2E tests for the `memory_tree_walk` agentic tool.
//!
//! These tests exercise `run_walk` end-to-end against a real HTTP mock
//! LLM server (wiremock) to prove that:
//! - The `OpenAiCompatibleProvider` → `run_walk` chain works over HTTP.
//! - Tool-call XML parsing, trace assembly, and stop-reason detection
//! all behave correctly with per-turn scripted LLM responses.
//! - Turn-cap enforcement fires correctly.
//! - Unknown node descends are handled gracefully.
//!
//! Tree nodes are seeded directly via `store::write_node` so the tests
//! are isolated from the summariser pipeline and focus purely on walk.
//!
//! Run with:
//! cargo test --test memory_tree_walk_e2e
//! or via the project wrapper:
//! bash scripts/test-rust-with-mock.sh --test memory_tree_walk_e2e
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use chrono::Utc;
use serde_json::json;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::inference::provider::compatible::{
AuthStyle, OpenAiCompatibleProvider,
};
use openhuman_core::openhuman::memory_tree::tools::walk::{run_walk, WalkOptions, WalkStopReason};
use openhuman_core::openhuman::memory_tree::tree_runtime::store::write_node;
use openhuman_core::openhuman::memory_tree::tree_runtime::types::{
derive_parent_id, estimate_tokens, level_from_node_id, TreeNode,
};
// ── Environment serialisation lock ──────────────────────────────────────────
//
// `OPENHUMAN_WORKSPACE` is a process-global env var. Tests that set it
// must serialise with this lock.
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(),
}
}
// ── Per-turn sequential mock responder ──────────────────────────────────────
//
// `ScriptedResponder` pops one canned OpenAI-compatible JSON response from its
// queue on each `respond` call. This allows a single wiremock `Mock` to hand
// back turn-1, turn-2, turn-3 responses in sequence.
//
// The queue is `Arc<Mutex<VecDeque<String>>>` so it can be shared between the
// `Mock` registration (which clones the arc) and the test body for inspection.
#[derive(Clone)]
struct ScriptedResponder {
queue: Arc<Mutex<VecDeque<String>>>,
call_count: Arc<Mutex<usize>>,
}
impl ScriptedResponder {
/// Create a new responder pre-loaded with `content_strings`.
///
/// Each string becomes the `choices[0].message.content` of an
/// OpenAI-compatible non-streaming response.
fn new(content_strings: Vec<&str>) -> Self {
log::debug!(
"[memory_tree_walk_e2e] ScriptedResponder created with {} turns",
content_strings.len()
);
let queue: VecDeque<String> = content_strings.iter().map(|s| s.to_string()).collect();
Self {
queue: Arc::new(Mutex::new(queue)),
call_count: Arc::new(Mutex::new(0)),
}
}
fn call_count(&self) -> usize {
*self.call_count.lock().unwrap()
}
}
impl Respond for ScriptedResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let mut queue = self.queue.lock().unwrap();
let mut count = self.call_count.lock().unwrap();
*count += 1;
let current_turn = *count;
let content = queue
.pop_front()
.unwrap_or_else(|| "ScriptedResponder: queue exhausted".to_string());
log::debug!(
"[memory_tree_walk_e2e] ScriptedResponder turn={current_turn} content_preview={}",
&content[..content.len().min(80)]
);
let body = json!({
"id": format!("chatcmpl-walk-e2e-{current_turn}"),
"object": "chat.completion",
"created": 1_700_000_000_u64,
"model": "e2e-walk-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": content
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 20,
"completion_tokens": 10,
"total_tokens": 30
}
});
ResponseTemplate::new(200).set_body_json(body)
}
}
// ── Tree helpers ─────────────────────────────────────────────────────────────
/// Build a minimal `Config` pointing at a temp workspace.
fn test_config(tmp: &TempDir) -> Config {
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&cfg.workspace_dir).expect("create workspace dir");
cfg
}
/// Create a `TreeNode` with the correct level and parent derived from its id.
fn make_node(namespace: &str, node_id: &str, summary: &str, child_count: u32) -> TreeNode {
let level = level_from_node_id(node_id);
let parent_id = derive_parent_id(node_id);
let ts = Utc::now();
TreeNode {
node_id: node_id.to_string(),
namespace: namespace.to_string(),
level,
parent_id,
summary: summary.to_string(),
token_count: estimate_tokens(summary),
child_count,
created_at: ts,
updated_at: ts,
metadata: None,
}
}
/// Seed a 3-level tree:
/// root → 2024 (year) → 2024/01 (month leaf)
///
/// Returns the node ids for easy reference in tests.
fn seed_tree(cfg: &Config, ns: &str) -> (&'static str, &'static str, &'static str) {
let root_id = "root";
let year_id = "2024";
let month_id = "2024/01";
write_node(
cfg,
&make_node(ns, root_id, "All-time summary: project activity 2024.", 1),
)
.expect("write root node");
write_node(
cfg,
&make_node(
ns,
year_id,
"Year 2024: major deployment cycle, shipped v1 release.",
1,
),
)
.expect("write year node");
write_node(
cfg,
&make_node(
ns,
month_id,
"January 2024: user discussed deployment X on the Slack channel.",
0,
),
)
.expect("write month node");
log::debug!(
"[memory_tree_walk_e2e] seeded tree namespace={ns} root={root_id} year={year_id} month={month_id}"
);
(root_id, year_id, month_id)
}
// ── Provider helper ───────────────────────────────────────────────────────────
/// Build an `OpenAiCompatibleProvider` pointing at `wiremock_uri/v1`.
fn make_provider(wiremock_uri: &str) -> OpenAiCompatibleProvider {
log::debug!(
"[memory_tree_walk_e2e] building provider base_url={}/v1",
wiremock_uri
);
OpenAiCompatibleProvider::new(
"e2e-walk-test",
&format!("{}/v1", wiremock_uri),
Some("test-key"),
AuthStyle::Bearer,
)
}
// ── Test 1: walks_descend_fetch_answer ───────────────────────────────────────
/// Happy-path walk: descend → fetch_leaves → answer in 3 turns.
///
/// Validates:
/// - `WalkStopReason::Answered`
/// - `answer` contains expected text
/// - trace has 3 steps with correct action names
/// - `turns_used == 3`
/// - mock LLM received exactly 3 HTTP calls
#[tokio::test]
async fn walks_descend_fetch_answer() {
let _lock = env_lock();
log::debug!("[memory_tree_walk_e2e] test=walks_descend_fetch_answer starting");
// ── Start wiremock ──
let server = MockServer::start().await;
log::debug!(
"[memory_tree_walk_e2e] wiremock listening at {}",
server.uri()
);
// ── Seed tree ──
let tmp = TempDir::new().expect("tempdir");
let cfg = test_config(&tmp);
let ns = "e2e-walk-test";
let (_, year_id, month_id) = seed_tree(&cfg, ns);
// ── Script 3 turns ──
// Turn 1: descend into the year node.
// Turn 2: fetch_leaves on the month node.
// Turn 3: answer.
let turn1 = format!(
r#"<tool_call>{{"name":"descend","arguments":{{"node_id":"{year_id}"}}}}</tool_call>"#
);
let turn2 = format!(
r#"<tool_call>{{"name":"fetch_leaves","arguments":{{"node_id":"{month_id}"}}}}</tool_call>"#
);
let turn3 =
r#"<tool_call>{"name":"answer","arguments":{"text":"The user discussed deployment X"}}</tool_call>"#.to_string();
let responder = ScriptedResponder::new(vec![&turn1, &turn2, &turn3]);
let responder_clone = responder.clone();
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(responder)
.mount(&server)
.await;
// ── Run walk ──
let provider = make_provider(&server.uri());
let opts = WalkOptions {
max_turns: 6,
start_node_id: None,
namespace: ns.to_string(),
model: Some("e2e-walk-model".into()),
};
log::debug!("[memory_tree_walk_e2e] calling run_walk query='deployment query'");
let outcome = run_walk(
&cfg,
&provider,
"What was discussed about deployment?",
opts,
)
.await
.expect("run_walk should succeed");
log::debug!(
"[memory_tree_walk_e2e] outcome stopped_reason={:?} turns_used={} trace_len={}",
outcome.stopped_reason,
outcome.turns_used,
outcome.trace.len()
);
// ── Assertions ──
assert_eq!(
outcome.stopped_reason,
WalkStopReason::Answered,
"walk should stop with Answered, got {:?}",
outcome.stopped_reason
);
assert!(
outcome.answer.contains("The user discussed deployment X"),
"answer should contain expected text, got: {}",
outcome.answer
);
assert_eq!(
outcome.turns_used, 3,
"expected 3 turns, got {}",
outcome.turns_used
);
assert_eq!(
outcome.trace.len(),
3,
"expected trace of 3 steps, got {}",
outcome.trace.len()
);
assert_eq!(
outcome.trace[0].action, "descend",
"step 0 should be descend"
);
assert_eq!(
outcome.trace[1].action, "fetch_leaves",
"step 1 should be fetch_leaves"
);
assert_eq!(outcome.trace[2].action, "answer", "step 2 should be answer");
// Verify LLM was called exactly 3 times over HTTP.
let llm_calls = responder_clone.call_count();
assert_eq!(
llm_calls, 3,
"LLM mock should have received exactly 3 HTTP requests, got {llm_calls}"
);
log::debug!("[memory_tree_walk_e2e] test=walks_descend_fetch_answer PASSED");
}
// ── Test 2: respects_max_turns_cap_with_mock ─────────────────────────────────
/// Turn-cap enforcement: the mock always returns `descend` (never `answer`),
/// so the walk must stop at `max_turns` with `MaxTurnsReached`.
///
/// Validates:
/// - `WalkStopReason::MaxTurnsReached`
/// - `turns_used == max_turns` (3)
/// - fallback answer contains "converge" or similar marker
/// - mock received exactly 3 HTTP calls (== max_turns)
#[tokio::test]
async fn respects_max_turns_cap_with_mock() {
let _lock = env_lock();
log::debug!("[memory_tree_walk_e2e] test=respects_max_turns_cap_with_mock starting");
let server = MockServer::start().await;
log::debug!(
"[memory_tree_walk_e2e] wiremock listening at {}",
server.uri()
);
let tmp = TempDir::new().expect("tempdir");
let cfg = test_config(&tmp);
let ns = "e2e-walk-cap-test";
let (_, year_id, _) = seed_tree(&cfg, ns);
// Always descend (never answer) — more entries than max_turns so the cap fires.
let forever_descend = format!(
r#"<tool_call>{{"name":"descend","arguments":{{"node_id":"{year_id}"}}}}</tool_call>"#
);
let responder = ScriptedResponder::new(vec![
&forever_descend,
&forever_descend,
&forever_descend,
&forever_descend,
&forever_descend,
]);
let responder_clone = responder.clone();
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(responder)
.mount(&server)
.await;
let provider = make_provider(&server.uri());
let opts = WalkOptions {
max_turns: 3,
start_node_id: None,
namespace: ns.to_string(),
model: Some("e2e-walk-model".into()),
};
log::debug!("[memory_tree_walk_e2e] calling run_walk with max_turns=3");
let outcome = run_walk(&cfg, &provider, "infinite loop query", opts)
.await
.expect("run_walk should succeed (not error)");
log::debug!(
"[memory_tree_walk_e2e] outcome stopped_reason={:?} turns_used={} trace_len={}",
outcome.stopped_reason,
outcome.turns_used,
outcome.trace.len()
);
assert_eq!(
outcome.stopped_reason,
WalkStopReason::MaxTurnsReached,
"walk should stop with MaxTurnsReached, got {:?}",
outcome.stopped_reason
);
assert_eq!(
outcome.turns_used, 3,
"turns_used should be max_turns=3, got {}",
outcome.turns_used
);
assert!(
outcome.answer.to_lowercase().contains("converge")
|| outcome.answer.to_lowercase().contains("turn limit")
|| outcome.answer.to_lowercase().contains("could not"),
"fallback answer should indicate failure to converge, got: {}",
outcome.answer
);
// Mock should have been called exactly max_turns (3) times.
let llm_calls = responder_clone.call_count();
assert_eq!(
llm_calls, 3,
"LLM mock should have received exactly 3 HTTP requests (max_turns), got {llm_calls}"
);
log::debug!("[memory_tree_walk_e2e] test=respects_max_turns_cap_with_mock PASSED");
}
// ── Test 3: handles_unknown_node_gracefully ───────────────────────────────────
/// Unknown-node recovery: turn 1 descends into a non-existent node;
/// the walk reports "unknown node" in the trace but continues.
/// Turn 2 answers, so the walk completes with `Answered`.
///
/// Validates:
/// - `WalkStopReason::Answered`
/// - `trace[0].result_preview` contains "unknown node"
/// - `trace.len() == 2`
/// - answer from turn 2 is preserved
#[tokio::test]
async fn handles_unknown_node_gracefully() {
let _lock = env_lock();
log::debug!("[memory_tree_walk_e2e] test=handles_unknown_node_gracefully starting");
let server = MockServer::start().await;
log::debug!(
"[memory_tree_walk_e2e] wiremock listening at {}",
server.uri()
);
let tmp = TempDir::new().expect("tempdir");
let cfg = test_config(&tmp);
let ns = "e2e-walk-unknown-test";
seed_tree(&cfg, ns);
// Turn 1: descend into a node that does not exist.
let turn1 =
r#"<tool_call>{"name":"descend","arguments":{"node_id":"does_not_exist"}}</tool_call>"#;
// Turn 2: answer (walk continues after bad descend).
let turn2 = r#"<tool_call>{"name":"answer","arguments":{"text":"I gave up: node not found"}}</tool_call>"#;
let responder = ScriptedResponder::new(vec![turn1, turn2]);
let responder_clone = responder.clone();
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(responder)
.mount(&server)
.await;
let provider = make_provider(&server.uri());
let opts = WalkOptions {
max_turns: 6,
start_node_id: None,
namespace: ns.to_string(),
model: Some("e2e-walk-model".into()),
};
log::debug!("[memory_tree_walk_e2e] calling run_walk with unknown-node script");
let outcome = run_walk(&cfg, &provider, "find nonexistent data", opts)
.await
.expect("run_walk should succeed");
log::debug!(
"[memory_tree_walk_e2e] outcome stopped_reason={:?} turns_used={} trace_len={}",
outcome.stopped_reason,
outcome.turns_used,
outcome.trace.len()
);
// Walk should complete despite the bad descend.
assert_eq!(
outcome.stopped_reason,
WalkStopReason::Answered,
"walk should eventually answer, got {:?}",
outcome.stopped_reason
);
assert_eq!(
outcome.trace.len(),
2,
"expected 2 trace steps, got {}",
outcome.trace.len()
);
// The first step's result_preview should indicate the unknown node.
let step0_preview = &outcome.trace[0].result_preview;
assert!(
step0_preview.contains("unknown node"),
"step 0 result_preview should contain 'unknown node', got: {step0_preview}"
);
// The final answer should be from turn 2.
assert!(
outcome.answer.contains("I gave up"),
"answer should contain turn-2 text, got: {}",
outcome.answer
);
// 2 HTTP calls made.
let llm_calls = responder_clone.call_count();
assert_eq!(
llm_calls, 2,
"LLM mock should have received exactly 2 HTTP requests, got {llm_calls}"
);
log::debug!("[memory_tree_walk_e2e] test=handles_unknown_node_gracefully PASSED");
}