260 lines
7.6 KiB
Rust
260 lines
7.6 KiB
Rust
//! Tests for multi-epoch session open, turns, trust and call ids. Do not edit.
|
|
|
|
mod support;
|
|
|
|
use bxcore::{CallId, DataClass, Epoch, LogRecord, SessionId, Timestamp};
|
|
use loopd::baseline::Baseline;
|
|
use loopd::session::{Session, SessionError};
|
|
use support::Home;
|
|
|
|
fn baseline() -> Baseline {
|
|
Baseline {
|
|
system: "sys".to_string(),
|
|
tools: vec![],
|
|
}
|
|
}
|
|
|
|
fn later() -> Baseline {
|
|
Baseline {
|
|
system: "sys-epoch-1".to_string(),
|
|
tools: vec![],
|
|
}
|
|
}
|
|
|
|
fn id(s: &str) -> SessionId {
|
|
SessionId::new(s).unwrap()
|
|
}
|
|
|
|
fn ts() -> Timestamp {
|
|
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
|
|
}
|
|
|
|
fn user(content: &str) -> LogRecord {
|
|
LogRecord::User {
|
|
time: ts(),
|
|
content: content.to_string(),
|
|
speaker: None,
|
|
}
|
|
}
|
|
|
|
fn write_jsonl(home: &Home, session: &str, epoch: u32, records: &[LogRecord]) {
|
|
let text: String = records
|
|
.iter()
|
|
.map(|r| format!("{}\n", serde_json::to_string(r).unwrap()))
|
|
.collect();
|
|
home.write(&format!("sessions/{session}/{epoch}.jsonl"), &text);
|
|
}
|
|
|
|
fn write_baseline(home: &Home, session: &str, epoch: u32, b: &Baseline) {
|
|
home.write(
|
|
&format!("sessions/{session}/{epoch}.baseline.json"),
|
|
&b.to_json().unwrap(),
|
|
);
|
|
}
|
|
|
|
fn start(session: &str, epoch: u32, slot: u32, b: &Baseline) -> LogRecord {
|
|
LogRecord::SessionStart {
|
|
time: ts(),
|
|
session: id(session),
|
|
epoch: Epoch(epoch),
|
|
slot,
|
|
baseline: b.hash().unwrap(),
|
|
}
|
|
}
|
|
|
|
fn epoch_end(next: u32, summary: &str) -> LogRecord {
|
|
LogRecord::EpochEnd {
|
|
time: ts(),
|
|
next: Epoch(next),
|
|
summary: summary.to_string(),
|
|
}
|
|
}
|
|
|
|
fn tool_result(call: u64, untrusted: bool) -> LogRecord {
|
|
LogRecord::ToolResult {
|
|
time: ts(),
|
|
call: CallId(call),
|
|
tool_call_id: format!("c{call}"),
|
|
content: "out".to_string(),
|
|
class: DataClass::Public,
|
|
untrusted,
|
|
truncated: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_loads_the_highest_complete_epoch_and_its_carry() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 3).unwrap();
|
|
s.append(user("one")).unwrap();
|
|
s.append(user("two")).unwrap();
|
|
s.append(epoch_end(1, "carry-text")).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
write_jsonl(&home, "a", 1, &[start("a", 1, 3, &later()), user("three")]);
|
|
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(s.epoch(), Epoch(1));
|
|
assert_eq!(s.slot(), 3);
|
|
assert_eq!(s.carry(), Some("carry-text"));
|
|
assert_eq!(s.baseline(), &later());
|
|
assert_eq!(s.records().len(), 2);
|
|
assert!(matches!(s.records()[1], LogRecord::User { ref content, .. } if content == "three"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_carry_on_epoch_1_is_an_error() {
|
|
let home = Home::new();
|
|
let s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later())]);
|
|
|
|
match Session::open(&home.dir, id("a")) {
|
|
Err(SessionError::NoCarry { ref path, .. }) => {
|
|
assert!(path.ends_with("0.jsonl"), "{path:?}");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
let e: Box<dyn std::error::Error> =
|
|
Box::new(Session::open(&home.dir, id("a")).map(|_| ()).unwrap_err());
|
|
assert!(e.to_string().contains("0.jsonl"), "{e}");
|
|
assert!(
|
|
e.to_string()
|
|
.contains("see docs/runbook.md#session-log-damaged"),
|
|
"{e}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn epoch_0_has_no_carry() {
|
|
let home = Home::new();
|
|
let s = Session::create(&home.dir, id("a"), baseline(), 7).unwrap();
|
|
drop(s);
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(s.epoch(), Epoch(0));
|
|
assert_eq!(s.carry(), None);
|
|
assert_eq!(s.slot(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn turns_count_users_across_every_epoch() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
s.append(user("one")).unwrap();
|
|
s.append(user("two")).unwrap();
|
|
s.append(epoch_end(1, "sum")).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later()), user("three")]);
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(s.turns(), 3);
|
|
assert_eq!(
|
|
s.records()
|
|
.iter()
|
|
.filter(|r| matches!(r, LogRecord::User { .. }))
|
|
.count(),
|
|
1,
|
|
"records() is the current epoch only"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn untrusted_reads_every_epoch() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
s.append(tool_result(1, true)).unwrap();
|
|
s.append(epoch_end(1, "sum")).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
write_jsonl(
|
|
&home,
|
|
"a",
|
|
1,
|
|
&[start("a", 1, 0, &later()), tool_result(2, false)],
|
|
);
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert!(s.untrusted(), "an untrusted result in epoch 0 still taints");
|
|
assert!(
|
|
!s.records().iter().any(|r| matches!(
|
|
r,
|
|
LogRecord::ToolResult {
|
|
untrusted: true,
|
|
..
|
|
}
|
|
)),
|
|
"the current epoch's records look clean"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn call_ids_continue_across_epochs() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
s.append(tool_result(4, false)).unwrap();
|
|
s.append(epoch_end(1, "sum")).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
write_jsonl(
|
|
&home,
|
|
"a",
|
|
1,
|
|
&[start("a", 1, 0, &later()), tool_result(2, false)],
|
|
);
|
|
let mut s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(
|
|
s.next_call(),
|
|
CallId(5),
|
|
"one more than the highest call in any epoch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn append_writes_the_current_epoch_file() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
s.append(epoch_end(1, "sum")).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later())]);
|
|
let mut s = Session::open(&home.dir, id("a")).unwrap();
|
|
s.append(user("after")).unwrap();
|
|
let text = home.read("sessions/a/1.jsonl");
|
|
assert!(text.contains("after"), "{text}");
|
|
let old = home.read("sessions/a/0.jsonl");
|
|
assert!(!old.contains("after"), "{old}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_incomplete_next_epoch_is_ignored() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
s.append(user("still-here")).unwrap();
|
|
drop(s);
|
|
write_baseline(&home, "a", 1, &later());
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(s.epoch(), Epoch(0));
|
|
assert_eq!(s.carry(), None);
|
|
assert_eq!(s.turns(), 1);
|
|
drop(s);
|
|
write_jsonl(&home, "a", 2, &[start("a", 2, 0, &later())]);
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(s.epoch(), Epoch(0), "a log without its baseline is ignored");
|
|
}
|
|
|
|
#[test]
|
|
fn epoch_end_without_the_next_files_stays_on_the_old_epoch() {
|
|
let home = Home::new();
|
|
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
|
s.append(user("x")).unwrap();
|
|
s.append(epoch_end(1, "sum")).unwrap();
|
|
drop(s);
|
|
let s = Session::open(&home.dir, id("a")).unwrap();
|
|
assert_eq!(s.epoch(), Epoch(0));
|
|
assert_eq!(s.carry(), None);
|
|
assert_eq!(s.turns(), 1);
|
|
assert!(
|
|
matches!(s.records().last(), Some(LogRecord::EpochEnd { next, .. }) if *next == Epoch(1))
|
|
);
|
|
}
|