Add the baseline and the log replay function
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
//! Tests for the baseline and the replay function. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use loopd::baseline::{Baseline, messages};
|
||||
use loopd::llama::ChatMessage;
|
||||
use loopd::tools::Registry;
|
||||
use proto::{CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, Timestamp, ToolCall};
|
||||
use std::path::Path;
|
||||
use support::Home;
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembles_system_prompt_and_core_schemas() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
assert_eq!(
|
||||
b.system, "You are Boxmaker, a test agent.",
|
||||
"trailing newline trimmed"
|
||||
);
|
||||
let names: Vec<&str> = b.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["clock", "find_tool", "call_tool"],
|
||||
"core tools, then the two meta-tools; not echo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_memory_is_appended_when_present_and_only_then() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
home.write("memory/core.md", "The owner likes cork.\n\n");
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
assert_eq!(
|
||||
b.system,
|
||||
"You are Boxmaker, a test agent.\n\nThe owner likes cork."
|
||||
);
|
||||
home.write("memory/core.md", " \n");
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
assert_eq!(
|
||||
b.system, "You are Boxmaker, a test agent.",
|
||||
"an empty core file adds nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_system_prompt_is_an_error_naming_the_file() {
|
||||
let home = Home::new();
|
||||
let mut cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
cfg.baseline.system = home.dir.join("nope.md");
|
||||
let e = Baseline::assemble(&cfg, &Registry::m2b()).unwrap_err();
|
||||
assert!(e.to_string().contains("nope.md"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_and_hash() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
let text = b.to_json().unwrap();
|
||||
assert_eq!(Baseline::from_json(&text).unwrap(), b);
|
||||
assert_eq!(
|
||||
b.hash().unwrap(),
|
||||
proto::sha256(text.as_bytes()).unwrap(),
|
||||
"the hash is of the JSON as written"
|
||||
);
|
||||
assert_ne!(b.hash().unwrap(), Hash32::ZERO);
|
||||
let mut other = b.clone();
|
||||
other.system.push('!');
|
||||
assert_ne!(other.hash().unwrap(), b.hash().unwrap());
|
||||
assert!(
|
||||
Baseline::from_json(&text.replacen("\"system\"", "\"zz\":1,\"system\"", 1)).is_err(),
|
||||
"unknown keys are rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_keeps_only_messages_in_order_and_unchanged() {
|
||||
let b = Baseline {
|
||||
system: "sys".to_string(),
|
||||
tools: vec![],
|
||||
};
|
||||
let call = ToolCall {
|
||||
id: "c1".to_string(),
|
||||
name: "clock".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
};
|
||||
let records = vec![
|
||||
LogRecord::SessionStart {
|
||||
time: ts(),
|
||||
session: SessionId::new("s").unwrap(),
|
||||
epoch: Epoch(0),
|
||||
slot: 0,
|
||||
baseline: Hash32::ZERO,
|
||||
},
|
||||
LogRecord::User {
|
||||
time: ts(),
|
||||
content: "hi".to_string(),
|
||||
},
|
||||
LogRecord::Assistant {
|
||||
time: ts(),
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
tool_calls: vec![call.clone()],
|
||||
},
|
||||
LogRecord::Usage {
|
||||
time: ts(),
|
||||
cache_n: 1,
|
||||
prompt_n: 2,
|
||||
predicted_n: 3,
|
||||
reasoning_tokens: 1,
|
||||
thinking_capped: false,
|
||||
},
|
||||
LogRecord::ToolResult {
|
||||
time: ts(),
|
||||
call: CallId(1),
|
||||
tool_call_id: "c1".to_string(),
|
||||
content: "noon".to_string(),
|
||||
class: DataClass::Public,
|
||||
untrusted: false,
|
||||
truncated: false,
|
||||
},
|
||||
LogRecord::CacheLoss {
|
||||
time: ts(),
|
||||
expected: 10,
|
||||
got: 0,
|
||||
},
|
||||
LogRecord::Assistant {
|
||||
time: ts(),
|
||||
content: Some("It is noon.".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
LogRecord::EpochEnd {
|
||||
time: ts(),
|
||||
next: Epoch(1),
|
||||
summary: "x".to_string(),
|
||||
},
|
||||
];
|
||||
let want = vec![
|
||||
ChatMessage::System {
|
||||
content: "sys".to_string(),
|
||||
},
|
||||
ChatMessage::User {
|
||||
content: "hi".to_string(),
|
||||
},
|
||||
ChatMessage::Assistant {
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
tool_calls: vec![call],
|
||||
},
|
||||
ChatMessage::Tool {
|
||||
tool_call_id: "c1".to_string(),
|
||||
content: "noon".to_string(),
|
||||
},
|
||||
ChatMessage::Assistant {
|
||||
content: Some("It is noon.".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
];
|
||||
assert_eq!(messages(&b, &records), want);
|
||||
assert_eq!(
|
||||
messages(&b, &[]),
|
||||
vec![ChatMessage::System {
|
||||
content: "sys".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaying a prefix of the log gives a prefix of the messages: the function never reorders
|
||||
/// or rewrites. Checked for every prefix of a generated log.
|
||||
#[test]
|
||||
fn replay_of_a_prefix_is_a_prefix() {
|
||||
let b = Baseline {
|
||||
system: "sys".to_string(),
|
||||
tools: vec![],
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
let mut seed: u64 = 0x9e3779b97f4a7c15;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
for i in 0..200u64 {
|
||||
let r = match next() % 5 {
|
||||
0 => LogRecord::User {
|
||||
time: ts(),
|
||||
content: format!("u{i}"),
|
||||
},
|
||||
1 => LogRecord::Assistant {
|
||||
time: ts(),
|
||||
content: Some(format!("a{i}")),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
2 => LogRecord::ToolResult {
|
||||
time: ts(),
|
||||
call: CallId(i),
|
||||
tool_call_id: format!("t{i}"),
|
||||
content: "x".repeat((next() % 50) as usize),
|
||||
class: DataClass::Private,
|
||||
untrusted: true,
|
||||
truncated: false,
|
||||
},
|
||||
3 => LogRecord::Usage {
|
||||
time: ts(),
|
||||
cache_n: i,
|
||||
prompt_n: 1,
|
||||
predicted_n: 1,
|
||||
reasoning_tokens: 0,
|
||||
thinking_capped: false,
|
||||
},
|
||||
_ => LogRecord::CacheLoss {
|
||||
time: ts(),
|
||||
expected: i,
|
||||
got: 0,
|
||||
},
|
||||
};
|
||||
records.push(r);
|
||||
}
|
||||
let whole = messages(&b, &records);
|
||||
for n in 0..=records.len() {
|
||||
let part = messages(&b, &records[..n]);
|
||||
assert_eq!(whole[..part.len()], part[..], "prefix of {n} records");
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,90 @@ retry_window_ms = 5000
|
||||
loopd::config::Config::parse(&text).unwrap()
|
||||
}
|
||||
|
||||
/// A temporary `BOXMAKER_HOME` with a `system.md` beside a `config.toml`, for session tests.
|
||||
pub struct Home {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
pub fn new() -> Home {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("loopd-home-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("system.md"), "You are Boxmaker, a test agent.\n").unwrap();
|
||||
Home { dir }
|
||||
}
|
||||
|
||||
/// A config for `socket` whose home, system prompt and channel socket are all under this
|
||||
/// directory, with the fast test limits.
|
||||
pub fn config(&self, socket: &Path) -> loopd::config::Config {
|
||||
let mut cfg = test_config(socket);
|
||||
cfg.paths.home = self.dir.clone();
|
||||
cfg.baseline.system = self.dir.join("system.md");
|
||||
cfg.channel.socket = self.dir.join("loop.sock");
|
||||
cfg
|
||||
}
|
||||
|
||||
pub fn write(&self, relative: &str, text: &str) {
|
||||
let path = self.dir.join(relative);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
pub fn read(&self, relative: &str) -> String {
|
||||
std::fs::read_to_string(self.dir.join(relative)).unwrap()
|
||||
}
|
||||
|
||||
/// The records of a session's log, epoch 0.
|
||||
pub fn records(&self, session: &str) -> Vec<proto::LogRecord> {
|
||||
let text = self.read(&format!("sessions/{session}/0.jsonl"));
|
||||
text.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool port that answers from a script and records what it was asked.
|
||||
pub struct ScriptedPort {
|
||||
replies: Mutex<VecDeque<proto::ToolResponse>>,
|
||||
calls: Mutex<Vec<proto::ToolRequest>>,
|
||||
}
|
||||
|
||||
impl ScriptedPort {
|
||||
/// Replies are given in order; when they run out, every call gets `fallback`.
|
||||
pub fn new(replies: Vec<proto::ToolResponse>) -> ScriptedPort {
|
||||
ScriptedPort {
|
||||
replies: Mutex::new(replies.into()),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calls(&self) -> Vec<proto::ToolRequest> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ok_result(content: &str) -> proto::ToolResponse {
|
||||
proto::ToolResponse::Result {
|
||||
content: content.to_string(),
|
||||
class: proto::DataClass::Private,
|
||||
untrusted: true,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
impl loopd::tools::ToolPort for ScriptedPort {
|
||||
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse {
|
||||
self.calls.lock().unwrap().push(request.clone());
|
||||
self.replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| ok_result("scripted"))
|
||||
}
|
||||
}
|
||||
|
||||
/// What an independent script derived from a recorded stream: `tests/fixtures/expected/`.
|
||||
pub fn expected(name: &str) -> serde_json::Value {
|
||||
serde_json::from_slice(&fixture_bytes("expected", &format!("{name}.json"))).unwrap()
|
||||
|
||||
Reference in New Issue
Block a user