//! `recall`'s Honcho part (M5c spec, section 8), against a fake Honcho that answers from the //! responses recorded from rift: every read workspace and the fleet's searched at once, never the //! agent's own; a source that hangs or fails is one `unavailable` line after its limit while the //! others answer; a missing workspace is no hits; a query holding a secret asks nothing; and the //! answer made from the local items and Honcho's, with the stricter label. Do not edit. #[path = "support/honcho.rs"] mod honcho; use std::collections::BTreeMap; use std::ffi::OsString; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use bxcore::{DataClass, RecallArgs}; use honcho::{FakeHoncho, Mode}; use memoryd::honcho::config::{HonchoConfig, ReadWorkspace, Trust}; use memoryd::honcho::read::{ReadTimeouts, Readers}; use memoryd::honcho::search::{SECRET, SOURCE_WAIT, Searched, answer, search}; use memoryd::recall::Local; use memoryd::serve::Log; const FAST: ReadTimeouts = ReadTimeouts { connect: Duration::from_secs(5), search: Duration::from_secs(5), chat: Duration::from_secs(5), }; /// The limit the tests give each source: long, so that a loaded machine never makes a source /// that answers late; a search where every source answers returns at once. const WAIT: Duration = Duration::from_secs(10); /// The limit when a source hangs: the others still answer well inside it. const HANG: Duration = Duration::from_millis(1_500); fn read(agent: &str, trust: Trust) -> ReadWorkspace { ReadWorkspace { agent: agent.to_string(), workspace: format!("{agent}-trusted"), trust, class: DataClass::Private, } } /// `boxmaker0`, owned by `kyle`, reading `boxmaker1` (labelled trusted) and `boxmaker2`. fn readers(fake: &FakeHoncho, read: Vec) -> Arc { let config = HonchoConfig { url: fake.url.clone(), agent: "boxmaker0".to_string(), owner: "kyle".to_string(), trusted: "boxmaker0-trusted".to_string(), untrusted: "boxmaker0-untrusted".to_string(), fleet: "fleet".to_string(), read, keys: BTreeMap::new(), }; let env = |_: &str| -> Option { None }; Arc::new(Readers::new(&config, &env, FAST).unwrap().0) } fn two(fake: &FakeHoncho) -> Arc { readers( fake, vec![ read("boxmaker1", Trust::Trusted), read("boxmaker2", Trust::Untrusted), ], ) } /// A log that keeps its lines. fn log() -> (Log, Arc>>) { let lines = Arc::new(Mutex::new(Vec::new())); let sink = Arc::clone(&lines); let log: Log = Arc::new(move |line: &str| sink.lock().unwrap().push(line.to_string())); (log, lines) } fn args(query: &str, k: u64) -> RecallArgs { RecallArgs { query: query.to_string(), k, } } fn nothing_local() -> Local { Local { items: Vec::new(), class: DataClass::Private, untrusted: false, } } fn asked(searched: &Searched) -> (Vec>, Vec) { match searched { Searched::Asked { found, unavailable } => ( found .iter() .map(|items| items.iter().map(|i| i.text.clone()).collect()) .collect(), unavailable.clone(), ), Searched::Secret => panic!("not asked"), } } #[test] fn the_limit() { assert_eq!(SOURCE_WAIT, Duration::from_secs(5)); assert_eq!( SECRET, "honcho: not asked; the query looks like it holds a secret" ); } /// A read agent's workspace: its messages, the conclusions about its peer and about the owner's; /// the fleet's: its messages. Each kept only with a word of the query, once. #[test] fn every_source_is_searched_with_its_calls() { let fake = FakeHoncho::start(); let (log, lines) = log(); let searched = search(&two(&fake), "kyle", &args("tea", 7), WAIT, &log); let (found, unavailable) = asked(&searched); let tea = [ "I prefer tea to coffee in the afternoon.", "Noted: tea in the afternoon.", ]; let with_owner = [tea[0], tea[1], "Kyle prefers tea in the afternoon."]; assert_eq!(found, [&with_owner[..], &with_owner[..], &tea[..]]); assert_eq!(unavailable, Vec::::new()); assert!(lines.lock().unwrap().is_empty()); let mut seen: Vec<(String, serde_json::Value)> = fake .requests() .into_iter() .map(|r| (r.path, r.body.unwrap_or_default())) .collect(); seen.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.to_string().cmp(&b.1.to_string()))); let query = |peer: &str| { serde_json::json!({ "query": "tea", "top_k": 7, "filters": { "observer_id": peer, "observed_id": peer } }) }; let search = serde_json::json!({ "query": "tea", "limit": 7 }); let w = "/v3/workspaces"; assert_eq!( seen, [ ( format!("{w}/boxmaker1-trusted/conclusions/query"), query("boxmaker1") ), ( format!("{w}/boxmaker1-trusted/conclusions/query"), query("kyle") ), (format!("{w}/boxmaker1-trusted/search"), search.clone()), ( format!("{w}/boxmaker2-trusted/conclusions/query"), query("boxmaker2") ), ( format!("{w}/boxmaker2-trusted/conclusions/query"), query("kyle") ), (format!("{w}/boxmaker2-trusted/search"), search.clone()), (format!("{w}/fleet/search"), search), ] ); } /// The agent's own workspaces hold what the local index holds: `recall` never searches them. #[test] fn the_agents_own_workspaces_are_never_searched() { let fake = FakeHoncho::start(); let (log, _) = log(); search(&two(&fake), "kyle", &args("tea", 5), WAIT, &log); let requests = fake.requests(); assert_eq!(requests.len(), 7); for request in requests { assert!(!request.path.contains("boxmaker0"), "{}", request.path); } } /// The answer shows each source's hits with its tag, numbered after the local items, one from /// each source in turn. #[test] fn the_answer_shows_each_sources_hits_with_its_tag() { let fake = FakeHoncho::start(); let (log, _) = log(); let searched = search(&two(&fake), "kyle", &args("tea", 4), WAIT, &log); let local = Local { items: vec![ "[1] memory · session mm-a, 2026-09-20 14:02 UTC, the owner said · private\n tea time" .to_string(), ], class: DataClass::Private, untrusted: false, }; let got = answer(local, "tea", 4, searched); let headers: Vec<&str> = got .text .lines() .filter(|l| l.starts_with('[')) .map(|l| l.split(" · ").next().unwrap()) .collect(); assert_eq!( headers, [ "[1] memory", "[2] memory", "[3] memory-tainted", "[4] memory-tainted" ] ); assert!( got.text.contains("\n[2] memory · boxmaker1: kyle said, "), "{}", got.text ); assert!( got.text .contains("\n[4] memory-tainted · fleet: kyle said, "), "{}", got.text ); assert_eq!((got.class, got.untrusted), (DataClass::Private, true)); } /// T58: a fleet hit makes the answer untrusted; a hit from a workspace the owner labelled trusted /// does not. #[test] fn a_fleet_hit_taints_the_answer_and_a_trusted_workspaces_does_not() { let fake = FakeHoncho::start(); let (log, _) = log(); let one = readers(&fake, vec![read("boxmaker1", Trust::Trusted)]); fake.set_workspace("fleet", Mode::Missing); let got = answer( nothing_local(), "tea", 5, search(&one, "kyle", &args("tea", 5), WAIT, &log), ); assert!( got.text.contains("[1] memory · boxmaker1: "), "{}", got.text ); assert_eq!((got.class, got.untrusted), (DataClass::Private, false)); fake.set_workspace("fleet", Mode::Fixtures); fake.set_workspace("boxmaker1-trusted", Mode::Missing); let got = answer( nothing_local(), "tea", 5, search(&one, "kyle", &args("tea", 5), WAIT, &log), ); assert!( got.text.starts_with("[1] memory-tainted · fleet: "), "{}", got.text ); assert_eq!((got.class, got.untrusted), (DataClass::Private, true)); } /// A source that does not answer is one `unavailable` line after its limit; the others answer. #[test] fn a_hanging_source_is_unavailable_and_the_others_still_answer() { let fake = FakeHoncho::start(); fake.set_workspace("boxmaker2-trusted", Mode::Hang); let (log, lines) = log(); let started = Instant::now(); let searched = search(&two(&fake), "kyle", &args("tea", 10), HANG, &log); let took = started.elapsed(); assert!(took >= HANG, "{took:?}"); assert!(took < Duration::from_secs(6), "{took:?}"); let (found, unavailable) = asked(&searched); assert_eq!(unavailable, ["boxmaker2"]); assert_eq!(found.iter().map(Vec::len).collect::>(), [3, 0, 2]); assert_eq!( *lines.lock().unwrap(), ["memoryd: recall: boxmaker2 unavailable: no answer in 1500 ms"] ); let got = answer(nothing_local(), "tea", 10, searched); assert!( got.text.ends_with("\nboxmaker2: unavailable"), "{}", got.text ); } /// The sources are asked at once: when every one hangs, the answer comes after one limit, not /// one per source. #[test] fn every_source_is_asked_at_once() { let fake = FakeHoncho::start(); fake.set(Mode::Hang); let (log, _) = log(); let started = Instant::now(); let (found, unavailable) = asked(&search(&two(&fake), "kyle", &args("tea", 5), HANG, &log)); let took = started.elapsed(); assert!(took < Duration::from_millis(4_500), "{took:?}"); assert_eq!(unavailable, ["boxmaker1", "boxmaker2", "fleet"]); assert!(found.iter().all(Vec::is_empty)); } /// A source whose call fails is unavailable, logged by the error's kind and status only. #[test] fn a_failing_source_is_unavailable_and_logged_without_the_query() { let fake = FakeHoncho::start(); fake.set_workspace("fleet", Mode::Status(503)); fake.set_workspace("boxmaker1-trusted", Mode::Status(422)); let (log, lines) = log(); let searched = search(&two(&fake), "kyle", &args("tea", 5), WAIT, &log); let (found, unavailable) = asked(&searched); assert_eq!(unavailable, ["boxmaker1", "fleet"]); assert_eq!(found[1].len(), 3); assert_eq!( *lines.lock().unwrap(), [ "memoryd: recall: boxmaker1 unavailable: Honcho refused the request (status, HTTP 422)", "memoryd: recall: fleet unavailable: Honcho is unreachable (server, HTTP 503)", ] ); let got = answer(nothing_local(), "tea", 5, searched); assert!( got.text .ends_with("\nboxmaker1: unavailable\nfleet: unavailable"), "{}", got.text ); } /// Honcho answers a search or a conclusion query in a workspace that does not exist with 200 and /// no hits: the source answered, with nothing. #[test] fn a_missing_workspace_is_no_hits() { let fake = FakeHoncho::start(); fake.set_workspace("boxmaker1-trusted", Mode::Missing); let (log, lines) = log(); let (found, unavailable) = asked(&search(&two(&fake), "kyle", &args("tea", 5), WAIT, &log)); assert_eq!(found[0], Vec::::new()); assert_eq!(unavailable, Vec::::new()); assert!(lines.lock().unwrap().is_empty()); } /// A query that holds a known-prefix secret is sent to no source (M5c spec, section 8). #[test] fn a_query_holding_a_secret_asks_nothing() { let fake = FakeHoncho::start(); let (log, _) = log(); let token = format!("{}{}", ["gh", "p_"].concat(), "A1b2".repeat(9)); let searched = search( &two(&fake), "kyle", &args(&format!("tea {token}"), 5), WAIT, &log, ); assert_eq!(searched, Searched::Secret); assert_eq!(fake.requests().len(), 0); let local = Local { items: vec!["[1] memory · x · private\n tea".to_string()], class: DataClass::Private, untrusted: false, }; let got = answer(local, "tea", 5, Searched::Secret); assert_eq!( got.text, format!("[1] memory · x · private\n tea\n{SECRET}") ); assert_eq!((got.class, got.untrusted), (DataClass::Private, false)); } /// Neither the local index nor Honcho has anything: the sentence local `recall` gives, then any /// `unavailable` lines. #[test] fn when_nothing_is_found_the_answer_says_so() { let fake = FakeHoncho::start(); let (log, _) = log(); let searched = search(&two(&fake), "kyle", &args("zebra", 5), WAIT, &log); let got = answer(nothing_local(), "zebra", 5, searched); assert_eq!(got.text, "(no matches for \"zebra\")"); assert_eq!((got.class, got.untrusted), (DataClass::Private, false)); let got = answer( nothing_local(), "zebra", 5, Searched::Asked { found: vec![Vec::new()], unavailable: vec!["fleet".to_string()], }, ); assert_eq!(got.text, "(no matches for \"zebra\")\nfleet: unavailable"); } /// The local index first: when its hits fill `k`, no Honcho hit is shown and none labels the /// answer. The label is the stricter of the two parts'. #[test] fn local_hits_come_first_and_the_labels_join() { let fake = FakeHoncho::start(); let (log, _) = log(); let local = Local { items: vec![ "[1] memory · a · secret\n tea".to_string(), "[2] memory · b · private\n tea".to_string(), ], class: DataClass::Secret, untrusted: false, }; let searched = search(&two(&fake), "kyle", &args("tea", 2), WAIT, &log); let got = answer(local.clone(), "tea", 2, searched.clone()); assert_eq!(got.text, local.items.join("\n")); assert_eq!((got.class, got.untrusted), (DataClass::Secret, false)); let got = answer(local, "tea", 3, searched); assert_eq!(got.text.lines().filter(|l| l.starts_with('[')).count(), 3); assert!( got.text.contains("\n[3] memory · boxmaker1: "), "{}", got.text ); assert_eq!((got.class, got.untrusted), (DataClass::Secret, false)); }