sync.rsannotatedsync.rssource243 lines · 9.8 KB · raw
1//! Two devices and the real service, end to end: what one learns, says and shows reaches the
2//! other.
3
4use std::net::TcpStream;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::time::{Duration, Instant};
8
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}