//! Every fail-closed message `loopd` produces ends with the runbook entry that explains it. //! `scripts/check-runbook.sh` checks that the entries exist; these tests check that the messages //! name them. Do not edit. mod support; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::{Child, Command, Stdio}; use std::time::Duration; use loopd::baseline::Baseline; use loopd::broker_port::{not_configured_line, unavailable_line}; use loopd::session::{Session, SessionError}; use loopd::tools::Registry; use proto::SessionId; use support::{FakeServer, Home, Reply}; // `want` is the whole pointer, written out: scripts/check-runbook.sh reads the anchors of every // pointer in the source, and cannot read one built with `format!`. fn ends_with_pointer(message: &str, want: &str) { assert!( message.ends_with(want), "{message:?} must end with {want:?}" ); assert_eq!( message.matches("runbook.md#").count(), 1, "one pointer, not two: {message:?}" ); } #[test] fn a_damaged_session_log_names_its_entry() { let home = Home::new(); let cfg = home.config(Path::new("/tmp/unused.sock")); let id = SessionId::new("a").unwrap(); let baseline = Baseline::assemble(&cfg, &Registry::m3a()).unwrap(); drop(Session::create(&home.dir, id.clone(), baseline, 0).unwrap()); let log = home.dir.join("sessions/a/0.jsonl"); let mut text = std::fs::read_to_string(&log).unwrap(); text.push_str("{\"type\":\"user\",\"time\":"); std::fs::write(&log, text).unwrap(); match Session::open(&home.dir, id) { Err(e @ SessionError::Torn { .. }) => { let message = e.to_string(); assert!(message.contains("0.jsonl:2: "), "{message}"); ends_with_pointer(&message, "see docs/runbook.md#session-log-damaged"); } Err(other) => panic!("{other:?}"), Ok(_) => panic!("a torn log was opened"), } } #[test] fn only_the_torn_error_points_at_the_damaged_log_entry() { let home = Home::new(); let missing = Session::open(&home.dir, SessionId::new("nobody").unwrap()); let message = match missing { Err(e) => e.to_string(), Ok(_) => panic!("a session nobody created was opened"), }; assert!(!message.contains("runbook"), "{message}"); } #[test] fn an_unreadable_core_memory_names_its_entry_and_a_missing_system_prompt_does_not() { let home = Home::new(); let cfg = home.config(Path::new("/tmp/unused.sock")); home.write("memory/core.md", "memory\n"); let core = home.dir.join("memory/core.md"); if !running_as_root() { std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap(); let result = Baseline::assemble(&cfg, &Registry::m3a()); std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap(); let message = result.expect_err("unreadable core.md").to_string(); assert!(message.contains("core.md"), "{message}"); ends_with_pointer(&message, "see docs/runbook.md#core-memory-unreadable"); } // The system prompt is a different file with a different remedy: no pointer to this entry. std::fs::remove_file(home.dir.join("system.md")).unwrap(); let message = Baseline::assemble(&cfg, &Registry::m3a()) .expect_err("no system.md") .to_string(); assert!(!message.contains("core-memory-unreadable"), "{message}"); } fn running_as_root() -> bool { std::fs::read_to_string("/proc/self/status") .map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t"))) .unwrap_or(false) } #[test] fn the_broker_lines_name_their_entry() { let line = unavailable_line("cannot connect to /run/x.sock: No such file"); assert_eq!( line, "loopd: the tool broker is unavailable: cannot connect to /run/x.sock: No such file; \ see docs/runbook.md#broker-unavailable" ); ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable"); let line = not_configured_line(); assert!( line.starts_with("loopd: no tool broker is configured"), "{line}" ); ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable"); } fn config_file( home: &Home, server: &FakeServer, expect_slots: u32, extra: &str, ) -> std::path::PathBuf { let text = format!( r#" [infer] socket = "{}" model = "test-model" [slots] main = 0 background = 1 [expect] template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" n_ctx = 131072 slots = {expect_slots} [limits] poll_ms = 40 liveness_ms = 500 retry_backoff_ms = [10] [paths] home = "{}" {extra} "#, server.socket.display(), home.dir.display() ); let path = home.dir.join("config.toml"); std::fs::write(&path, text).unwrap(); path } fn healthy_routes(server: &FakeServer) { server.route("/props", vec![Reply::fixture("props")]); server.route( "/v1/chat/completions", vec![ Reply::fixture("tool_call"), Reply::fixture("turn1"), Reply::fixture("turn2"), Reply::fixture("plain"), ], ); } /// Waits for `loopd serve` to bind its socket, stops it, and returns what it printed. fn stderr_once_serving(mut child: Child, socket: &Path) -> String { let mut up = false; for _ in 0..200 { if socket.exists() { up = true; break; } std::thread::sleep(Duration::from_millis(50)); } child.kill().unwrap(); let output = child.wait_with_output().unwrap(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); assert!(up, "the socket never appeared; stderr: {stderr}"); stderr } fn serve(config: &Path) -> Child { Command::new(env!("CARGO_BIN_EXE_loopd")) .args(["serve", "--config"]) .arg(config) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .unwrap() } #[test] fn a_failed_self_test_names_its_entry() { let home = Home::new(); let server = FakeServer::start(); healthy_routes(&server); // The server has two slots; the config expects three. let config = config_file(&home, &server, 3, ""); for command in ["selftest", "serve"] { let output = Command::new(env!("CARGO_BIN_EXE_loopd")) .args([command, "--config"]) .arg(&config) .output() .unwrap(); let stderr = String::from_utf8_lossy(&output.stderr); assert_eq!(output.status.code(), Some(1), "{command}: {stderr}"); let line = stderr .lines() .find(|l| l.starts_with("selftest: FAILED: ")) .unwrap_or_else(|| panic!("{command}: no FAILED line: {stderr}")); ends_with_pointer(line, "see docs/runbook.md#loopd-selftest-failed"); } } #[test] fn serve_without_a_broker_says_so_once_with_the_entry() { let home = Home::new(); let server = FakeServer::start(); healthy_routes(&server); let config = config_file(&home, &server, 2, ""); let socket = home.dir.join("run/loop/loop.sock"); let stderr = stderr_once_serving(serve(&config), &socket); let lines: Vec<&str> = stderr .lines() .filter(|l| l.contains("no tool broker is configured")) .collect(); assert_eq!(lines.len(), 1, "once, at startup: {stderr}"); ends_with_pointer(lines[0], "see docs/runbook.md#broker-unavailable"); } #[test] fn serve_with_a_broker_socket_does_not_say_it() { let home = Home::new(); let server = FakeServer::start(); healthy_routes(&server); // The socket need not exist: loopd connects per call, and a missing broker is not a reason // to refuse to start. let extra = format!( "[broker]\nsocket = \"{}\"\n", home.dir.join("run/loop-broker/broker.sock").display() ); let config = config_file(&home, &server, 2, &extra); let socket = home.dir.join("run/loop/loop.sock"); let stderr = stderr_once_serving(serve(&config), &socket); assert!(!stderr.contains("no tool broker"), "{stderr}"); assert!(stderr.contains("serving on"), "{stderr}"); }