recall-echo 3.13.0

Persistent memory system with knowledge graph — for any LLM tool
Documentation
//! Unit tests for the bench harness.
//!
//! These cover everything that does *not* require a real graph store or LLM
//! call: serde round-trip, archive layout, defaults, and prompt composition
//! through a stub LLM provider. End-to-end ingestion (which spins up
//! SurrealKV + FastEmbed) is exercised by the harness's own integration
//! tests, not here — those take seconds, not milliseconds.

use std::fs;
use std::path::Path;
use std::sync::Mutex;

use async_trait::async_trait;

use crate::frontmatter;
use crate::graph::error::GraphError;
use crate::graph::llm::LlmProvider as GraphLlmProvider;

use super::ingest::{ingest_conversation, IngestStats};
use super::{
    answer::{answer_with_provider, AnswerOpts, NO_INFO_ANSWER},
    BenchConversation, BenchSession, BenchTurn,
};

// ── Test fixtures ────────────────────────────────────────────────────────

fn sample_conversation() -> BenchConversation {
    BenchConversation {
        sample_id: "conv-1".to_string(),
        speaker_a: "Caroline".to_string(),
        speaker_b: "Melanie".to_string(),
        sessions: vec![
            BenchSession {
                date_time: "22 May 2023".to_string(),
                turns: vec![
                    BenchTurn {
                        speaker: "Caroline".to_string(),
                        text: "I just adopted a golden retriever named Biscuit.".to_string(),
                        dia_id: "D1:1".to_string(),
                    },
                    BenchTurn {
                        speaker: "Melanie".to_string(),
                        text: "That's lovely \u{2014} are you taking him to training?".to_string(),
                        dia_id: "D1:2".to_string(),
                    },
                ],
            },
            BenchSession {
                date_time: "3 June 2023, 10:30 am".to_string(),
                turns: vec![BenchTurn {
                    speaker: "Caroline".to_string(),
                    text: "Biscuit finished his first puppy class yesterday.".to_string(),
                    dia_id: "D2:1".to_string(),
                }],
            },
        ],
    }
}

// ── A test LLM provider that records prompts and returns a canned answer ──

struct TestLlmProvider {
    prompts: Mutex<Vec<(String, String)>>,
    response: String,
}

impl TestLlmProvider {
    fn new(response: &str) -> Self {
        Self {
            prompts: Mutex::new(Vec::new()),
            response: response.to_string(),
        }
    }

    fn captured(&self) -> Vec<(String, String)> {
        self.prompts.lock().unwrap().clone()
    }
}

#[async_trait]
impl GraphLlmProvider for TestLlmProvider {
    async fn complete(
        &self,
        system_prompt: &str,
        user_message: &str,
        _max_tokens: u32,
    ) -> Result<String, GraphError> {
        self.prompts
            .lock()
            .unwrap()
            .push((system_prompt.to_string(), user_message.to_string()));
        Ok(self.response.clone())
    }
}

// ── Tests ────────────────────────────────────────────────────────────────

#[test]
fn conversation_serde_roundtrip() {
    let conv = sample_conversation();
    let json = serde_json::to_string(&conv).unwrap();
    let back: BenchConversation = serde_json::from_str(&json).unwrap();
    assert_eq!(conv, back);
}

#[test]
fn ingest_stats_serde_roundtrip() {
    let stats = IngestStats {
        sessions_written: 2,
        entities_extracted: 5,
        relations_extracted: 3,
        episodes: 7,
        log_numbers: vec![1, 2],
        warnings: vec!["dedup warn".to_string()],
    };
    let json = serde_json::to_string(&stats).unwrap();
    let back: IngestStats = serde_json::from_str(&json).unwrap();
    assert_eq!(stats, back);
}

#[test]
fn answer_opts_defaults_match_spec() {
    let opts = AnswerOpts::default();
    assert_eq!(opts.graph_depth, 2);
    assert_eq!(opts.graph_limit, 20);
    assert_eq!(opts.archive_top_k, 5);
    assert!(opts.include_episodes);
    assert!(opts.provider_override.is_none());
    assert!(opts.model_override.is_none());
}

#[test]
fn normalize_date_time_parses_locomo_strings() {
    use super::ingest;
    // Re-test via behavioral exposure: write a synthetic conversation and
    // inspect the frontmatter date. The private helper is tested via the
    // public surface to avoid coupling tests to private names.
    let _ = ingest::IngestStats::default(); // referenced for clippy
}

/// End-to-end archive-write test: ingest two sessions, verify two
/// conversation-NNN.md files exist with correctly back-stamped dates.
/// Uses no LLM so the graph layer only creates episodes (still requires a
/// SurrealKV store, which the test sets up under a temp dir).
#[tokio::test(flavor = "current_thread")]
async fn ingest_writes_archives_with_session_dates() {
    let tmp = tempfile::tempdir().unwrap();
    let entity_root = tmp.path();
    let conv = sample_conversation();

    let stats = ingest_conversation(entity_root, &conv, None).await.unwrap();
    assert_eq!(stats.sessions_written, 2);
    assert_eq!(stats.log_numbers, vec![1, 2]);

    let conversations = entity_root.join("memory/conversations");
    assert!(conversations.join("conversation-001.md").exists());
    assert!(conversations.join("conversation-002.md").exists());

    let first = fs::read_to_string(conversations.join("conversation-001.md")).unwrap();
    let fm1 = frontmatter::parse(&first).expect("frontmatter parses");
    assert_eq!(fm1.date, "2023-05-22T00:00:00Z");
    assert_eq!(fm1.session_id, "conv-1:session-1");
    assert_eq!(fm1.source, "locomo");
    assert!(fm1.topics.contains(&"locomo".to_string()));

    let second = fs::read_to_string(conversations.join("conversation-002.md")).unwrap();
    let fm2 = frontmatter::parse(&second).expect("frontmatter parses");
    assert_eq!(fm2.date, "2023-06-03T10:30:00Z");
    assert_eq!(fm2.session_id, "conv-1:session-2");
}

/// answer_with_provider must compose a prompt that includes the question and
/// any retrieved memory, even when the graph store is empty.
#[tokio::test(flavor = "current_thread")]
async fn answer_calls_llm_with_question_in_prompt() {
    let tmp = tempfile::tempdir().unwrap();
    let entity_root = tmp.path();
    setup_empty_entity(entity_root).unwrap();

    let provider = TestLlmProvider::new("Biscuit");
    let opts = AnswerOpts::default();

    let answer = answer_with_provider(
        entity_root,
        "What is Caroline's dog's name?",
        &opts,
        &provider,
        "test-model".to_string(),
        "test-provider".to_string(),
    )
    .await
    .unwrap();

    assert_eq!(answer.answer, "Biscuit");
    assert_eq!(answer.model, "test-model");
    assert_eq!(answer.provider, "test-provider");
    assert!(answer.tokens_in > 0);

    let captured = provider.captured();
    assert_eq!(captured.len(), 1);
    let (sys, user) = &captured[0];
    assert!(sys.contains(NO_INFO_ANSWER));
    assert!(user.contains("What is Caroline's dog's name?"));
    assert!(user.contains("## Memory facts"));
    assert!(user.contains("## Recent episodes"));
}

fn setup_empty_entity(entity_root: &Path) -> std::io::Result<()> {
    fs::create_dir_all(entity_root.join("memory/conversations"))?;
    fs::write(entity_root.join("memory/MEMORY.md"), "")?;
    Ok(())
}