Files
boxmaker/crates/memoryd/tests/honcho_turns.rs
T

429 lines
16 KiB
Rust

//! Deciding reported turns (M5c spec, section 5): every undecided turn of every waiting session up
//! to the one reported, as far as the log goes; the log's last turn waits for its reply; one
//! question to `brokerd` per session per walk, and none when nothing waits; a silent `brokerd`
//! stops the walk and is logged once, and so is its return; a damaged log and a ledger that cannot
//! be written. `brokerd` is a closure here; the socket is in `honcho_state.rs`. Do not edit.
#[path = "support/logs.rs"]
mod logs;
#[path = "support/tmp.rs"]
mod tmp;
#[path = "support/turns.rs"]
mod turns;
use std::cell::RefCell;
use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply, Timestamp};
use logs::{assistant, write_log};
use memoryd::honcho::decide::{NO_REPLY, NO_SPEAKER, SECRET_CLASS};
use memoryd::honcho::ledger::{Action, Item, Ledger, Workspace};
use memoryd::honcho::turns::{
LEDGER, LOG_RUNBOOK, TurnSync, decidable, finished, log_path, read_log,
};
use tmp::TempDir;
use turns::{clean, fetched, running, sid, spoken, write_turns};
const BROKER_DOWN: &str = "see docs/runbook.md#broker-state-unavailable";
const BACK: &str = "memoryd: brokerd answers on its state socket again; turns are decided";
/// A home with a ledger, a `TurnSync`, and a `brokerd` whose answer the test sets.
struct World {
sync: TurnSync,
answer: RefCell<Result<SessionStateReply, String>>,
asked: RefCell<Vec<String>>,
lines: RefCell<Vec<String>>,
dir: TempDir,
}
impl World {
fn new(tag: &str) -> World {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("memory")).unwrap();
let ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
World {
sync: TurnSync::new(ledger, "boxmaker0", &dir.path().join("sessions")),
answer: RefCell::new(Ok(clean())),
asked: RefCell::new(Vec::new()),
lines: RefCell::new(Vec::new()),
dir,
}
}
fn log(&self, session: &str, turns: &[Vec<LogRecord>]) {
write_turns(self.dir.path(), session, turns);
}
fn updated(&mut self, session: &str, turn: u64) {
let ask = |s: &SessionId| {
self.asked.borrow_mut().push(s.as_str().to_string());
self.answer.borrow().clone()
};
let log = |line: &str| self.lines.borrow_mut().push(line.to_string());
let now = Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap();
self.sync.updated(&sid(session), turn, &ask, now, &log);
}
fn action(&self, session: &str, turn: u64) -> Option<Action> {
let item = Item::Turn {
session: sid(session),
turn,
};
let path = self.dir.path().join("memory").join(LEDGER);
let text = std::fs::read_to_string(path).unwrap_or_default();
let key = serde_json::to_string(&item).unwrap();
let line = text.lines().find(|l| l.contains(&key))?;
let value: serde_json::Value = serde_json::from_str(line).unwrap();
Some(serde_json::from_value(value["action"].clone()).unwrap())
}
fn waiting(&self) -> Vec<(String, u64)> {
let waiting = self.sync.waiting();
waiting
.into_iter()
.map(|(s, n)| (s.as_str().to_string(), n))
.collect()
}
}
fn to(workspace: Workspace, speaker: &str) -> Option<Action> {
Some(Action::Turn {
workspace,
speaker: speaker.to_string(),
})
}
fn skip(why: &str) -> Option<Action> {
Some(Action::Skip {
why: why.to_string(),
})
}
#[test]
fn the_reported_turn_is_decided_and_written_at_once() {
let mut w = World::new("turns-one");
w.log("mm-a", &[turns::finished(Some("kyle"))]);
w.updated("mm-a", 1);
assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle"));
assert_eq!(*w.asked.borrow(), vec!["mm-a"]);
assert!(w.waiting().is_empty());
assert!(w.lines.borrow().is_empty(), "{:?}", w.lines.borrow());
let reopened = Ledger::open(&w.dir.path().join("memory").join(LEDGER)).unwrap();
assert_eq!(reopened.counts().decided, 1);
}
#[test]
fn every_undecided_turn_up_to_the_reported_one_is_decided() {
let mut w = World::new("turns-all");
w.log(
"mm-a",
&[
turns::finished(None),
running(Some("kyle")),
fetched(Some("kyle")),
turns::finished(Some("kyle")),
],
);
w.updated("mm-a", 4);
assert_eq!(w.action("mm-a", 1), skip(NO_SPEAKER));
assert_eq!(w.action("mm-a", 2), skip(NO_REPLY));
assert_eq!(w.action("mm-a", 3), to(Workspace::Untrusted, "kyle"));
assert_eq!(w.action("mm-a", 4), to(Workspace::Untrusted, "kyle"));
assert_eq!(*w.asked.borrow(), vec!["mm-a"], "one question per session");
}
#[test]
fn only_as_far_as_the_report_and_the_log_go() {
let mut w = World::new("turns-limit");
w.log(
"mm-a",
&[turns::finished(Some("kyle")), turns::finished(Some("kyle"))],
);
w.updated("mm-a", 9);
assert!(w.action("mm-a", 2).is_some());
assert!(w.waiting().is_empty(), "turns not in the log do not wait");
w.updated("mm-a", 9);
assert_eq!(
w.asked.borrow().len(),
1,
"asked about turns not in the log"
);
w.log(
"mm-b",
&[
turns::finished(Some("kyle")),
turns::finished(Some("kyle")),
turns::finished(Some("kyle")),
],
);
w.updated("mm-b", 1);
assert!(w.action("mm-b", 1).is_some());
assert_eq!(w.action("mm-b", 2), None, "turn 2 was not reported");
assert!(w.waiting().is_empty());
}
#[test]
fn the_logs_last_turn_waits_for_its_reply() {
let mut w = World::new("turns-running");
w.log(
"mm-a",
&[turns::finished(Some("kyle")), running(Some("kyle"))],
);
w.updated("mm-a", 2);
assert!(w.action("mm-a", 1).is_some());
assert_eq!(w.action("mm-a", 2), None, "a running turn was decided");
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 2)]);
// Another report while it runs: it still waits, and nothing is asked for it.
w.log("mm-c", &[turns::finished(Some("kyle"))]);
w.updated("mm-c", 1);
assert_eq!(w.action("mm-a", 2), None);
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 2)]);
assert_eq!(*w.asked.borrow(), vec!["mm-a", "mm-c"]);
// It ends; the next report of any session decides it.
let mut ended = running(Some("kyle"));
ended.push(assistant(3, Some("found it"), &[]));
w.log("mm-a", &[turns::finished(Some("kyle")), ended]);
w.log("mm-b", &[turns::finished(Some("ana"))]);
w.updated("mm-b", 1);
assert_eq!(w.action("mm-a", 2), to(Workspace::Trusted, "kyle"));
assert_eq!(w.action("mm-b", 1), to(Workspace::Trusted, "ana"));
assert!(w.waiting().is_empty());
assert_eq!(w.sync.ledger().counts().skipped, 0);
}
#[test]
fn a_report_past_the_log_does_not_decide_a_running_turn() {
let mut w = World::new("turns-inflated");
w.log(
"mm-a",
&[turns::finished(Some("kyle")), running(Some("kyle"))],
);
w.updated("mm-a", 5);
assert!(w.action("mm-a", 1).is_some());
assert_eq!(w.action("mm-a", 2), None, "a running turn was decided");
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 5)]);
}
#[test]
fn an_earlier_report_does_not_lower_the_turn() {
let mut w = World::new("turns-order");
*w.answer.borrow_mut() = Err("no".to_string());
w.log(
"mm-a",
&[
turns::finished(Some("kyle")),
turns::finished(Some("kyle")),
turns::finished(Some("kyle")),
],
);
w.updated("mm-a", 3);
w.updated("mm-a", 1);
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 3)]);
*w.answer.borrow_mut() = Ok(clean());
w.log("mm-b", &[turns::finished(Some("kyle"))]);
w.updated("mm-b", 1);
assert_eq!(w.action("mm-a", 3), to(Workspace::Trusted, "kyle"));
}
#[test]
fn a_silent_brokerd_is_logged_once_and_its_return_once() {
let mut w = World::new("turns-down");
let why = "/h/run/memory-broker/state.sock: Connection refused (os error 111)";
*w.answer.borrow_mut() = Err(why.to_string());
w.log("mm-a", &[turns::finished(Some("kyle"))]);
w.log("mm-b", &[turns::finished(Some("kyle"))]);
w.updated("mm-a", 1);
w.updated("mm-b", 1);
w.updated("mm-a", 1);
assert_eq!(w.action("mm-a", 1), None);
assert_eq!(w.action("mm-b", 1), None);
assert_eq!(
*w.lines.borrow(),
vec![format!(
"memoryd: {why}; turns wait undecided; {BROKER_DOWN}"
)]
);
assert_eq!(w.waiting().len(), 2);
*w.answer.borrow_mut() = Ok(clean());
w.log("mm-c", &[turns::finished(Some("kyle"))]);
w.updated("mm-c", 1);
for session in ["mm-a", "mm-b", "mm-c"] {
assert_eq!(
w.action(session, 1),
to(Workspace::Trusted, "kyle"),
"{session}"
);
}
assert_eq!(w.lines.borrow().len(), 2);
assert_eq!(w.lines.borrow()[1], BACK);
assert!(w.waiting().is_empty());
}
#[test]
fn a_silent_brokerd_stops_the_walk_at_the_first_question() {
let mut w = World::new("turns-stop");
*w.answer.borrow_mut() = Err("no".to_string());
for session in ["mm-a", "mm-b", "mm-c"] {
w.log(session, &[turns::finished(Some("kyle"))]);
}
w.updated("mm-a", 1);
w.updated("mm-b", 1);
w.updated("mm-c", 1);
// One question per walk, to the first waiting session in the order of the ids.
assert_eq!(*w.asked.borrow(), vec!["mm-a", "mm-a", "mm-a"]);
*w.answer.borrow_mut() = Ok(clean());
w.updated("mm-c", 1);
assert_eq!(w.asked.borrow()[3..], ["mm-a", "mm-b", "mm-c"]);
}
#[test]
fn nothing_undecided_asks_nothing() {
let mut w = World::new("turns-quiet");
w.log("mm-a", &[turns::finished(Some("kyle"))]);
w.updated("mm-a", 1);
w.updated("mm-a", 1);
assert_eq!(*w.asked.borrow(), vec!["mm-a"]);
let lines = std::fs::read_to_string(w.dir.path().join("memory").join(LEDGER)).unwrap();
assert_eq!(lines.lines().count(), 1);
// A restart reads the ledger through: nothing is decided twice.
let ledger = Ledger::open(&w.dir.path().join("memory").join(LEDGER)).unwrap();
w.sync = TurnSync::new(ledger, "boxmaker0", &w.dir.path().join("sessions"));
w.updated("mm-a", 1);
assert_eq!(w.asked.borrow().len(), 1);
}
#[test]
fn brokerds_state_decides_the_workspace() {
let mut w = World::new("turns-state");
w.log("mm-a", &[turns::finished(Some("kyle"))]);
*w.answer.borrow_mut() = Ok(SessionStateReply {
class: DataClass::Private,
untrusted: true,
});
w.updated("mm-a", 1);
assert_eq!(w.action("mm-a", 1), to(Workspace::Untrusted, "kyle"));
w.log("mm-b", &[turns::finished(Some("kyle"))]);
*w.answer.borrow_mut() = Ok(SessionStateReply {
class: DataClass::Secret,
untrusted: false,
});
w.updated("mm-b", 1);
assert_eq!(w.action("mm-b", 1), skip(SECRET_CLASS));
}
#[test]
fn a_damaged_log_waits_and_is_logged_once() {
let mut w = World::new("turns-damaged");
let path = write_log(w.dir.path(), "mm-a", &turns::finished(Some("kyle")));
let text = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, format!("not json\n{text}")).unwrap();
w.log("mm-b", &[turns::finished(Some("kyle"))]);
w.updated("mm-a", 1);
w.updated("mm-b", 1);
assert_eq!(w.action("mm-a", 1), None);
assert_eq!(w.action("mm-b", 1), to(Workspace::Trusted, "kyle"));
let lines = w.lines.borrow().clone();
assert_eq!(lines.len(), 1, "{lines:?}");
let start = format!("memoryd: {}:1: ", path.display());
assert!(lines[0].starts_with(&start), "{}", lines[0]);
assert!(
lines[0].ends_with(&format!("; {LOG_RUNBOOK}")),
"{}",
lines[0]
);
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 1)]);
assert_eq!(*w.asked.borrow(), vec!["mm-b"]);
// Mended, it is decided at the next report.
std::fs::write(&path, text).unwrap();
w.updated("mm-b", 1);
assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle"));
}
#[test]
fn a_line_loopd_is_still_writing_is_left_out() {
let mut w = World::new("turns-partial");
let path = write_log(w.dir.path(), "mm-a", &turns::finished(Some("kyle")));
let half = serde_json::to_string(&spoken("next", Some("kyle"))).unwrap();
let mut text = std::fs::read_to_string(&path).unwrap();
text.push_str(&half[..half.len() / 2]);
std::fs::write(&path, text).unwrap();
w.updated("mm-a", 1);
assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle"));
assert!(w.lines.borrow().is_empty(), "{:?}", w.lines.borrow());
}
#[test]
fn a_ledger_that_cannot_be_written_stops_the_walk() {
let mut w = World::new("turns-ledger");
w.log("mm-a", &[turns::finished(Some("kyle"))]);
let ledger = w.dir.path().join("memory").join(LEDGER);
std::fs::create_dir(&ledger).unwrap();
w.updated("mm-a", 1);
let lines = w.lines.borrow().clone();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with(&format!("memoryd: {}: ", ledger.display())));
assert!(lines[0].ends_with("; see docs/runbook.md#honcho-ledger-damaged"));
assert!(!w.sync.ledger().is_decided(&Item::Turn {
session: sid("mm-a"),
turn: 1
}));
assert_eq!(w.waiting(), vec![("mm-a".to_string(), 1)]);
std::fs::remove_dir(&ledger).unwrap();
w.updated("mm-a", 1);
assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle"));
}
#[test]
fn reading_a_log() {
let dir = TempDir::new("turns-read");
let path = log_path(&dir.path().join("sessions"), &sid("mm-a"));
assert_eq!(path, dir.path().join("sessions/mm-a/0.jsonl"));
assert_eq!(read_log(&path), Ok(Vec::new()), "a missing log");
let records = turns::finished(Some("kyle"));
write_log(dir.path(), "mm-a", &records);
assert_eq!(read_log(&path), Ok(records.clone()));
let mut text = std::fs::read_to_string(&path).unwrap();
text.push_str("{\"type\":\"us");
std::fs::write(&path, &text).unwrap();
assert_eq!(read_log(&path), Ok(records));
std::fs::write(&path, "{\"type\":\"user\"}\n").unwrap();
let err = read_log(&path).unwrap_err();
assert!(err.starts_with(&format!("{}:1: ", path.display())), "{err}");
assert!(err.ends_with(&format!("; {LOG_RUNBOOK}")), "{err}");
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let err = read_log(&path).unwrap_err();
assert!(err.starts_with(&format!("{}: ", path.display())), "{err}");
assert_eq!(LOG_RUNBOOK, "see docs/runbook.md#session-log-damaged");
assert_eq!(LEDGER, "honcho.jsonl");
}
#[test]
fn a_turn_is_finished_by_an_answer_without_tool_calls() {
assert!(finished(&turns::finished(Some("kyle"))));
assert!(finished(&fetched(None)));
assert!(!finished(&running(None)));
assert!(!finished(&[spoken("hello", None)]));
let mut empty = vec![spoken("hello", None), assistant(2, None, &[])];
assert!(finished(&empty), "an empty answer still ends the turn");
empty.push(assistant(3, Some("and"), &[("fetch", "{}")]));
assert!(!finished(&empty));
}
#[test]
fn how_far_a_log_may_be_decided() {
let done = |n: usize| -> Vec<LogRecord> {
(0..n).flat_map(|_| turns::finished(Some("kyle"))).collect()
};
assert_eq!(decidable(&[], 3), (0, false));
assert_eq!(decidable(&done(3), 0), (0, false));
assert_eq!(decidable(&done(3), 2), (2, false));
assert_eq!(decidable(&done(3), 3), (3, false));
assert_eq!(decidable(&done(3), 7), (3, false));
let mut open = done(2);
open.extend(running(Some("kyle")));
assert_eq!(decidable(&open, 2), (2, false));
assert_eq!(decidable(&open, 3), (2, true));
assert_eq!(decidable(&open, 9), (2, true));
assert_eq!(decidable(&running(None), 1), (0, true));
}