Two devices and the real service, end to end: what one learns, says and shows reaches the other.
9use whiskers_core::Image; 10use whiskers_engine::{Engine, EngineConfig}; 11 12struct Service { 13 child: Child, 14 url: String, 15} 16 17impl Drop for Service { 18 fn drop(&mut self) { 19 let _ = self.child.kill(); 20 let _ = self.child.wait(); 21 } 22} 23 24fn scratch(name: &str) -> PathBuf { 25 let d = std::env::temp_dir().join(format!("whiskers-sync-{name}-{}", std::process::id())); 26 let _ = std::fs::remove_dir_all(&d); 27 std::fs::create_dir_all(&d).unwrap(); 28 d 29} 30 31fn start_service(home: &Path) -> Service { 32 let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); 33 let addr = format!("127.0.0.1:{port}"); 34 let child = Command::new(env!("CARGO_BIN_EXE_whiskersd")) 35 .arg(&addr) 36 .env("HOME", home) 37 .env("WHISKERS_MODEL_URL", "http://127.0.0.1:1") 38 // Nothing here may reach a real embedding server that happens to be listening on this machine. 39 .env("WHISKERS_EMBED_URL", "http://127.0.0.1:1") 40 .env_remove("TYPESAFE_API_KEY") 41 .env_remove("ELEVENLABS_API_KEY") 42 .stdout(Stdio::null()) 43 .stderr(Stdio::null()) 44 .spawn() 45 .unwrap(); 46 let up = Instant::now(); 47 while TcpStream::connect(&addr).is_err() { 48 assert!(up.elapsed() < Duration::from_secs(20), "the service did not start"); 49 std::thread::sleep(Duration::from_millis(50)); 50 } 51 Service { child, url: format!("http://{addr}") } 52} 53 54fn device(dir: &Path, url: &str) -> Engine { 55 Engine::new(EngineConfig { 56 data_dir: dir.to_owned(), 57 gateway_url: url.to_owned(), 58 guard_url: url.to_owned(), 59 model: "m".into(), 60 system_prompt: None, 61 }) 62 .unwrap() 63} 64 65#[test] 66fn everything_one_device_knows_reaches_the_other() { 67 let home = scratch("home"); 68 let svc = start_service(&home); 69 let (a_dir, b_dir) = (scratch("a"), scratch("b")); 70 71 // Device A already knows two things. 72 std::fs::write( 73 a_dir.join("memory.json"), 74 r#"{"next_id":3,"facts":[{"id":1,"text":"Biscuit is her toy bunny","learned_at_ms":5,"gid":"g-1"},{"id":2,"text":"She loves purple","learned_at_ms":6,"gid":"g-2"}]}"#, 75 ) 76 .unwrap(); 77 let a = device(&a_dir, &svc.url); 78 let b = device(&b_dir, &svc.url); 79 80 // A says something and shows a picture. (The model is not reachable here, which is fine: 81 // what was heard and shown is logged first.) 82 a.greet(); 83 a.hear("hello whiskers", vec![]); 84 a.hear("look at this", vec![Image { media_type: "image/jpeg".into(), bytes: vec![0xFF, 0xD8, 1, 2, 3] }]); 85 a.sync().expect("A syncs"); 86 87 // B is brand new and empty, and would say hello as if they had never met. 88 assert!(b.facts().is_empty()); 89 assert!(b.greeting_due()); 90 b.sync().expect("B syncs"); 91 let mut got: Vec<String> = b.facts().into_iter().map(|f| f.text).collect(); 92 got.sort(); 93 assert_eq!(got, vec!["Biscuit is her toy bunny".to_string(), "She loves purple".to_string()], "what A learned reached B"); 94 95 // There is one session: B carries on from A and does not introduce itself again. 96 assert!(!b.greeting_due(), "the session is the same on every device"); 97 98 // B's parents' view shows A's conversations, picture included. 99 let d = b.digest(0, u64::MAX).unwrap(); 100 let heard: Vec<&str> = d.exchanges.iter().map(|e| e.heard.as_str()).collect(); 101 assert_eq!(heard, vec!["hello whiskers", "look at this"]); 102 let shown = d.exchanges.iter().flat_map(|e| e.pictures.iter()).next().expect("the picture is in the log"); 103 assert!(b.picture_path(shown).is_file(), "and the picture file came with it"); 104 assert_eq!(std::fs::read(b.picture_path(shown)).unwrap(), vec![0xFF, 0xD8, 1, 2, 3]); 105 106 // Syncing again changes nothing. 107 assert_eq!(b.sync().unwrap(), 0); 108 assert_eq!(a.sync().unwrap_or(0), a.sync().unwrap_or(0)); 109 110 // The grown-ups change a choice on B; A hears it. 111 let mut c = b.household_config(); 112 c.limits = whiskers_core::Limits::new(Some(30), None).unwrap(); 113 b.set_household_config(c); 114 b.sync().unwrap(); 115 a.sync().unwrap(); 116 assert_eq!(a.household_config().limits.daily_minutes(), Some(30)); 117 118 // The PIN and the voice allowance are choices like any other: one value, everywhere. 119 let mut c = b.household_config(); 120 c.pin = Some("salt:hash".into()); 121 c.voice_daily_chars = 2000; 122 b.set_household_config(c); 123 b.sync().unwrap(); 124 a.sync().unwrap(); 125 assert_eq!(a.household_config().pin.as_deref(), Some("salt:hash")); 126 assert_eq!(a.household_config().voice_daily_chars, 2000); 127 128 // So is the look of the cat: grey until the grown-ups choose, then the same on every device. 129 assert_eq!(a.household_config().cat_theme, whiskers_core::CatTheme::Grey); 130 let mut c = b.household_config(); 131 c.cat_theme = whiskers_core::CatTheme::Ginger; 132 b.set_household_config(c); 133 b.sync().unwrap(); 134 a.sync().unwrap(); 135 assert_eq!(a.household_config().cat_theme, whiskers_core::CatTheme::Ginger); 136 137 // The child's profile is a choice like any other, and the other device speaks to it at once. 138 assert!(a.greet().text.starts_with("Hi there!"), "no profile yet: neutral"); 139 let mut c = b.household_config(); 140 c.child = Some(whiskers_core::Child { 141 name: whiskers_core::ChildName::new("Ada").unwrap(), 142 age: whiskers_core::Age::new(6).unwrap(), 143 }); 144 b.set_household_config(c); 145 b.sync().unwrap(); 146 a.sync().unwrap(); 147 assert_eq!(a.household_config().child.as_ref().map(|c| c.age.years()), Some(6)); 148 assert!(a.greet().text.starts_with("Hi Ada!")); 149 150 // A parent forgets a memory on B; it is gone on A too. 151 let id = b.facts().iter().find(|f| f.text.contains("purple")).unwrap().id; 152 assert!(b.forget(id)); 153 b.sync().unwrap(); 154 a.sync().unwrap(); 155 assert!(a.facts().iter().all(|f| !f.text.contains("purple"))); 156} 157 158#[test] 159fn a_session_the_service_cannot_keep_is_a_sync_error_and_the_rest_of_the_sync_still_happens() { 160 let home = scratch("home3"); 161 // The hub's chat file is a directory, so no copy of the session can ever be saved there. 162 std::fs::create_dir_all(home.join(".local/share/whiskers/chat-hub.json")).unwrap(); 163 let svc = start_service(&home); 164 let (a_dir, b_dir) = (scratch("a3"), scratch("b3")); 165 let a = device(&a_dir, &svc.url); 166 a.greet(); 167 a.hear("hello whiskers", vec![]); 168 std::fs::write(a_dir.join("memory.json"), r#"{"next_id":2,"facts":[{"id":1,"text":"Biscuit is her toy bunny","learned_at_ms":5,"gid":"g-1"}]}"#).unwrap(); 169 let a = device(&a_dir, &svc.url); 170 a.hear("hello again", vec![]); 171 172 let err = a.sync().expect_err("the service answered 500 for the session"); 173 assert!(err.contains("session") && err.contains("500"), "{err}"); 174 assert!(!err.contains("memory") && !err.contains("conversations") && !err.contains("settings"), "only the session failed: {err}"); 175 176 // Everything else went through: B (which has no session of its own to send, so needs no write) hears 177 // the memory and the conversation. 178 let b = device(&b_dir, &svc.url); 179 b.sync().expect("B has nothing the service must keep"); 180 assert!(b.greeting_due(), "the session itself never reached the service"); 181 assert_eq!(b.facts().into_iter().map(|f| f.text).collect::<Vec<_>>(), vec!["Biscuit is her toy bunny".to_string()]); 182 let heard: Vec<String> = b.digest(0, u64::MAX).unwrap().exchanges.iter().map(|e| e.heard.clone()).collect(); 183 assert_eq!(heard, vec!["hello whiskers", "hello again"]); 184} 185 186#[test] 187fn a_service_that_is_not_there_is_an_error_and_changes_nothing() { 188 let dir = scratch("alone"); 189 let e = device(&dir, "http://127.0.0.1:1"); 190 assert!(e.sync().is_err()); 191 assert!(e.facts().is_empty()); 192} 193 194#[test] 195fn syncs_at_the_same_moment_do_not_duplicate_the_log_and_a_damaged_copy_repairs_itself() { 196 let home = scratch("home2"); 197 let svc = start_service(&home); 198 let (a_dir, b_dir) = (scratch("a2"), scratch("b2")); 199 let a = device(&a_dir, &svc.url); 200 a.hear("one", vec![]); 201 a.hear("two", vec![]); 202 a.sync().unwrap(); 203 204 let b = std::sync::Arc::new(device(&b_dir, &svc.url)); 205 let racers: Vec<_> = (0..4) 206 .map(|_| { 207 let b = b.clone(); 208 std::thread::spawn(move || { 209 let _ = b.sync(); 210 }) 211 }) 212 .collect(); 213 for r in racers { 214 r.join().unwrap(); 215 } 216 let heard = |e: &Engine| e.digest(0, u64::MAX).unwrap().exchanges.iter().map(|x| x.heard.clone()).collect::<Vec<_>>(); 217 assert_eq!(heard(&b), vec!["one", "two"], "each exchange once, however many syncs raced"); 218 let lines = |p: &Path| std::fs::read_to_string(p).map(|t| t.lines().count()).unwrap_or(0); 219 let replica = b_dir.join("shared-log.jsonl"); 220 let in_the_log = lines(&a_dir.join("log.jsonl")); 221 assert!(in_the_log >= 4); 222 assert_eq!(lines(&replica), in_the_log, "the copy has each entry of the one log exactly once"); 223 224 // A copy that has somehow grown longer than the log (a copy damaged on the device) is rebuilt. 225 let text = std::fs::read_to_string(&replica).unwrap(); 226 std::fs::write(&replica, format!("{text}{text}")).unwrap(); 227 assert_eq!(lines(&replica), in_the_log * 2); 228 b.sync().unwrap(); 229 assert_eq!(lines(&replica), in_the_log); 230 assert_eq!(heard(&b), vec!["one", "two"]); 231} 232 233#[test] 234fn the_service_will_not_start_without_a_model_gateway_address() { 235 let out = Command::new(env!("CARGO_BIN_EXE_whiskersd")) 236 .arg("127.0.0.1:0") 237 .env_remove("WHISKERS_MODEL_URL") 238 .env_remove("TYPESAFE_API_KEY") 239 .output() 240 .unwrap(); 241 assert_eq!(out.status.code(), Some(2)); 242 assert!(String::from_utf8_lossy(&out.stderr).contains("WHISKERS_MODEL_URL")); 243}