docs: M5d-1 and M5d-2 implementation plans for Ornith

Eight closed tasks for epochs and compaction, then ten for the scheduler,
ToolSummary, and tiers. Given tests first. Gate 1456 → 1507 → 1572.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
This commit is contained in:
2026-10-02 21:54:37 -07:00
co-authored by Grok 4.6
parent 069cd1686a
commit 3726ae585f
48 changed files with 7319 additions and 0 deletions
@@ -0,0 +1,120 @@
//! Tests for the carried summary in the next epoch's prefix. Do not edit.
use bxcore::{Epoch, Hash32, LogRecord, SessionId, Timestamp};
use loopd::baseline::{Baseline, CARRY_HEADING, messages, messages_with_carry};
use loopd::llama::ChatMessage;
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 start(epoch: u32) -> LogRecord {
LogRecord::SessionStart {
time: ts(),
session: SessionId::new("a").unwrap(),
epoch: Epoch(epoch),
slot: 0,
baseline: Hash32::ZERO,
}
}
#[test]
fn epoch_0_matches_messages_without_a_carry() {
let b = Baseline {
system: "sys".to_string(),
tools: vec![],
};
let records = vec![start(0), user("hi")];
assert_eq!(
messages_with_carry(&b, &records, None),
messages(&b, &records)
);
}
#[test]
fn a_carry_is_a_user_message_after_the_system_prompt() {
let b = Baseline {
system: "new-sys".to_string(),
tools: vec![],
};
let records = vec![start(1), user("next")];
let got = messages_with_carry(&b, &records, Some("the summary"));
assert_eq!(
got,
vec![
ChatMessage::System {
content: "new-sys".to_string(),
},
ChatMessage::User {
content: format!("{CARRY_HEADING}\n\nthe summary"),
},
ChatMessage::User {
content: "next".to_string(),
},
]
);
}
#[test]
fn the_carry_is_not_taken_from_a_log_user_record() {
let b = Baseline {
system: "sys".to_string(),
tools: vec![],
};
let records = vec![start(1)];
let got = messages_with_carry(&b, &records, Some("carried"));
assert_eq!(got.len(), 2);
assert!(
!records.iter().any(|r| matches!(r, LogRecord::User { .. })),
"the carry is not in the log"
);
}
#[test]
fn the_heading_is_built_from_pieces() {
assert_eq!(CARRY_HEADING, concat!("Earlier ", "turns, summarised:"));
assert!(
!CARRY_HEADING.contains('<'),
"the heading is words, not a tag"
);
}
#[test]
fn epoch_1_is_not_an_extension_of_epoch_0() {
let old = Baseline {
system: "old-sys".to_string(),
tools: vec![],
};
let new = Baseline {
system: "new-sys".to_string(),
tools: vec![],
};
let epoch0 = vec![start(0), user("first")];
let epoch1 = vec![start(1), user("second")];
let prev = messages(&old, &epoch0);
let next = messages_with_carry(&new, &epoch1, Some("sum of first"));
assert!(
next.len() >= 2,
"system plus carry, then the new epoch's user"
);
assert_ne!(next[0], prev[0], "the system message changed");
assert!(
!next.starts_with(&prev[..]),
"the new prefix is not an extension of the old messages"
);
match &next[1] {
ChatMessage::User { content } => {
assert!(content.contains("sum of first"), "{content}");
assert!(!content.contains("second"), "{content}");
}
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,201 @@
//! Tests for writing the next epoch. Do not edit.
mod support;
use bxcore::{Epoch, LogRecord, SessionId};
use loopd::baseline::Baseline;
use loopd::compact;
use loopd::memory::{MemorySource, NoMemory};
use loopd::session::Session;
use loopd::tools::Registry;
use support::Home;
struct Block(&'static str);
impl MemorySource for Block {
fn block(&self) -> Result<String, String> {
Ok(self.0.to_string())
}
fn updated(&self, _session: &SessionId, _turn: u64) -> Result<(), String> {
Ok(())
}
}
fn id(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
fn session(home: &Home) -> (loopd::config::Config, Session) {
let cfg = home.config(std::path::Path::new("/tmp/unused.sock"));
let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
let s = Session::create(&home.dir, id("a"), baseline, cfg.slots.main).unwrap();
(cfg, s)
}
#[test]
fn commit_writes_epoch_end_a_new_baseline_and_a_new_log() {
let home = Home::new();
let (cfg, mut s) = session(&home);
s.append(LogRecord::User {
time: bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(),
content: "hi".to_string(),
speaker: None,
})
.unwrap();
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"the sum".to_string(),
)
.unwrap();
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(s.carry(), Some("the sum"));
assert_eq!(s.slot(), cfg.slots.main);
assert_eq!(s.turns(), 1, "the old user still counts");
assert_eq!(
s.records()
.iter()
.filter(|r| matches!(r, LogRecord::User { .. }))
.count(),
0
);
let old = home.records("a");
assert!(
matches!(old.last(), Some(LogRecord::EpochEnd { next, summary, .. }) if *next == Epoch(1) && summary == "the sum")
);
assert!(home.dir.join("sessions/a/1.baseline.json").is_file());
let new_log = home.read("sessions/a/1.jsonl");
assert!(new_log.contains("\"epoch\":1"), "{new_log}");
}
#[test]
fn the_new_baseline_takes_a_fresh_memory_block() {
let home = Home::new();
let (cfg, mut s) = session(&home);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&Block("I take my tea strong."),
"sum".to_string(),
)
.unwrap();
assert!(
s.baseline().system.contains("I take my tea strong."),
"{}",
s.baseline().system
);
let on_disk = home.read("sessions/a/1.baseline.json");
assert!(on_disk.contains("I take my tea strong."), "{on_disk}");
}
#[test]
fn reopen_loads_the_new_epoch() {
let home = Home::new();
let (cfg, mut s) = session(&home);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"carry".to_string(),
)
.unwrap();
drop(s);
let s = Session::open(&home.dir, id("a")).unwrap();
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(s.carry(), Some("carry"));
}
#[test]
fn a_second_commit_does_not_write_another_epoch_end_on_the_old_log() {
let home = Home::new();
let (cfg, mut s) = session(&home);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"first".to_string(),
)
.unwrap();
let ends = home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::EpochEnd { .. }))
.count();
assert_eq!(ends, 1);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"second".to_string(),
)
.unwrap();
assert_eq!(s.epoch(), Epoch(2));
let ends = home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::EpochEnd { .. }))
.count();
assert_eq!(ends, 1, "epoch 0 still has one EpochEnd");
let mid = home.read("sessions/a/1.jsonl");
assert!(mid.contains("\"type\":\"epoch_end\""), "{mid}");
}
#[test]
fn resume_after_epoch_end_without_next_files_does_not_write_a_second_end() {
let home = Home::new();
let (cfg, mut s) = session(&home);
s.append(LogRecord::EpochEnd {
time: bxcore::Timestamp::now(),
next: Epoch(1),
summary: "already".to_string(),
})
.unwrap();
drop(s);
let mut s = Session::open(&home.dir, id("a")).unwrap();
assert_eq!(s.epoch(), Epoch(0));
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"ignored".to_string(),
)
.unwrap();
let ends = home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::EpochEnd { .. }))
.count();
assert_eq!(ends, 1);
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(
s.carry(),
Some("already"),
"the summary already in EpochEnd is the carry"
);
}
#[test]
fn the_new_session_start_keeps_the_live_slot() {
let home = Home::new();
let cfg = home.config(std::path::Path::new("/tmp/unused.sock"));
let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
let mut s = Session::create(&home.dir, id("a"), baseline, 7).unwrap();
compact::commit(&mut s, &cfg, &Registry::m2b(), &NoMemory, "s".to_string()).unwrap();
assert_eq!(s.slot(), 7);
match &s.records()[0] {
LogRecord::SessionStart { slot, epoch, .. } => {
assert_eq!(*slot, 7);
assert_eq!(*epoch, Epoch(1));
}
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,79 @@
//! Tests for when compaction is due and for the fallback summary. Do not edit.
use bxcore::{Epoch, Hash32, LogRecord, SessionId, Timestamp, Usage};
use loopd::compact::{due, fallback_summary};
fn usage(cache_n: u64, prompt_n: u64) -> Usage {
Usage {
cache_n,
prompt_n,
predicted_n: 0,
reasoning_tokens: 0,
thinking_capped: false,
}
}
fn user(content: &str) -> LogRecord {
LogRecord::User {
time: Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(),
content: content.to_string(),
speaker: None,
}
}
#[test]
fn due_at_the_threshold_and_not_below() {
assert!(!due(&usage(400, 449), 0.85, 1_000), "849 < 850");
assert!(due(&usage(400, 450), 0.85, 1_000), "850 == 0.85 * 1000");
assert!(due(&usage(0, 851), 0.85, 1_000));
assert!(!due(&usage(0, 0), 0.85, 1_000));
}
#[test]
fn due_uses_this_agents_n_ctx() {
// 0.85 * 262144 = 222822.4, so 222823 is the first integer at or above.
assert!(!due(&usage(0, 222_822), 0.85, 262_144));
assert!(due(&usage(0, 222_823), 0.85, 262_144));
assert!(due(&usage(200_000, 22_823), 0.85, 262_144));
}
#[test]
fn a_threshold_of_one_is_the_full_window() {
assert!(!due(&usage(99, 0), 1.0, 100));
assert!(due(&usage(100, 0), 1.0, 100));
}
#[test]
fn fallback_is_user_texts_newest_last() {
let records = vec![
LogRecord::SessionStart {
time: Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(),
session: SessionId::new("a").unwrap(),
epoch: Epoch(0),
slot: 0,
baseline: Hash32::ZERO,
},
user("first"),
user("second"),
user("third"),
];
assert_eq!(
fallback_summary(&records, 4_000),
"third\n\nsecond\n\nfirst"
);
}
#[test]
fn fallback_is_cut_to_summary_chars_on_a_character_boundary() {
let records = vec![user("café"), user("ab")];
assert_eq!(fallback_summary(&records, 3), "ab\n");
assert_eq!(fallback_summary(&records, 1), "a");
assert_eq!(fallback_summary(&records, 0), "");
let long = user("ååå");
assert_eq!(fallback_summary(&[long], 2), "åå");
}
#[test]
fn fallback_with_no_users_is_empty() {
assert_eq!(fallback_summary(&[], 100), "");
}
@@ -0,0 +1,147 @@
//! Tests for the compaction summary call. Do not edit.
mod support;
use bxcore::SessionId;
use loopd::baseline::Baseline;
use loopd::compact::{self, CompactError};
use loopd::llama::Client;
use loopd::session::Session;
use loopd::tools::Registry;
use support::{FakeServer, Home, Reply};
const CHAT: &str = "/v1/chat/completions";
const PROMPT: &str = "Summarise this conversation for the next epoch.\n";
fn sse(content: &str, cache_n: u64, prompt_n: u64) -> Reply {
let content_json = serde_json::to_string(content).unwrap();
let data1 = format!(
r#"data: {{"choices":[{{"finish_reason":null,"index":0,"delta":{{"content":{content_json}}}}}],"id":"sum","timings":{{"cache_n":{cache_n},"prompt_n":{prompt_n},"predicted_n":1}}}}"#
);
let data2 = format!(
r#"data: {{"choices":[{{"finish_reason":"stop","index":0,"delta":{{}}}}],"id":"sum","timings":{{"cache_n":{cache_n},"prompt_n":{prompt_n},"predicted_n":1}}}}"#
);
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn session(home: &Home, cfg: &loopd::config::Config) -> Session {
let baseline = Baseline::assemble(cfg, &Registry::m2b()).unwrap();
Session::create(
&home.dir,
SessionId::new("a").unwrap(),
baseline,
cfg.slots.main,
)
.unwrap()
}
#[test]
fn the_summary_call_is_on_the_background_slot_with_no_tools_and_thinking() {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse("the sum", 0, 10)]);
let client = Client::new(cfg.clone());
let s = session(&home, &cfg);
let text = compact::summarise(&cfg, &client, &s).unwrap();
assert_eq!(text, "the sum");
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
let body = sent[0].json();
assert_eq!(body["id_slot"], 1);
assert!(body.get("tools").is_none(), "{body}");
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], true);
let messages = body["messages"].as_array().unwrap();
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"], PROMPT.trim_end());
}
#[test]
fn the_summary_is_neutralised_and_capped() {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summary_chars = 4;
server.route(CHAT, vec![sse("abcdefghij", 0, 10)]);
let client = Client::new(cfg.clone());
let s = session(&home, &cfg);
let text = compact::summarise(&cfg, &client, &s).unwrap();
assert_eq!(text, "abcd");
}
#[test]
fn a_context_full_summary_uses_the_fallback() {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![Reply::fixture("context_full")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(bxcore::LogRecord::User {
time: bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(),
content: "remember the tea".to_string(),
speaker: None,
})
.unwrap();
let text = compact::summarise(&cfg, &client, &s).unwrap();
assert_eq!(text, "remember the tea");
}
#[test]
fn tags_in_the_completion_are_neutralised() {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
let tag = concat!("<", "think", ">");
server.route(CHAT, vec![sse(&format!("hello {tag} world"), 0, 10)]);
let client = Client::new(cfg.clone());
let s = session(&home, &cfg);
let text = compact::summarise(&cfg, &client, &s).unwrap();
assert!(!text.contains(&tag), "{text}");
assert!(text.contains("hello"), "{text}");
}
#[test]
fn a_missing_prompt_file_names_the_runbook() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
let client = Client::new(cfg.clone());
let s = session(&home, &cfg);
let err = compact::summarise(&cfg, &client, &s).unwrap_err();
let e: Box<dyn std::error::Error> = Box::new(err);
assert!(e.to_string().contains("compact.md"), "{e}");
assert!(
e.to_string()
.ends_with("see docs/runbook.md#session-compact-failed"),
"{e}"
);
}
#[test]
fn an_inference_error_that_is_not_context_full_is_a_compact_failure() {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![Reply::fixture("bad_request")]);
let client = Client::new(cfg.clone());
let s = session(&home, &cfg);
let err = compact::summarise(&cfg, &client, &s).unwrap_err();
assert!(matches!(err, CompactError::Failed { .. }), "{err:?}");
let e: Box<dyn std::error::Error> = Box::new(err);
assert!(
e.to_string()
.ends_with("see docs/runbook.md#session-compact-failed"),
"{e}"
);
}
@@ -0,0 +1,257 @@
//! Tests for compacting after a turn and retrying a full context. Do not edit.
mod support;
use bxcore::{Epoch, LogRecord, SessionId, TurnEvent};
use loopd::baseline::Baseline;
use loopd::compact;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, TurnError, run_turn};
use support::{FakeServer, Home, Reply};
const CHAT: &str = "/v1/chat/completions";
const PROMPT: &str = "Summarise this conversation for the next epoch.\n";
fn sse(content: &str, cache_n: u64, prompt_n: u64) -> Reply {
let content_json = serde_json::to_string(content).unwrap();
let data1 = format!(
r#"data: {{"choices":[{{"finish_reason":null,"index":0,"delta":{{"content":{content_json}}}}}],"id":"t","timings":{{"cache_n":{cache_n},"prompt_n":{prompt_n},"predicted_n":1}}}}"#
);
let data2 = format!(
r#"data: {{"choices":[{{"finish_reason":"stop","index":0,"delta":{{}}}}],"id":"t","timings":{{"cache_n":{cache_n},"prompt_n":{prompt_n},"predicted_n":1}}}}"#
);
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
struct World {
home: Home,
server: FakeServer,
cfg: loopd::config::Config,
client: Client,
port: support::ScriptedPort,
registry: Registry,
}
impl World {
fn new() -> World {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.expect.n_ctx = 1_000;
cfg.compact.threshold = 0.85;
let client = Client::new(cfg.clone());
World {
home,
server,
cfg,
client,
port: support::ScriptedPort::new(vec![]),
registry: Registry::m2b(),
}
}
fn session(&self) -> Session {
let baseline = Baseline::assemble(&self.cfg, &self.registry).unwrap();
Session::create(
&self.home.dir,
SessionId::new("a").unwrap(),
baseline,
self.cfg.slots.main,
)
.unwrap()
}
fn runtime(&self) -> Runtime<'_> {
Runtime {
cfg: &self.cfg,
client: &self.client,
port: &self.port,
registry: &self.registry,
}
}
fn finish(
&self,
session: &mut Session,
text: &str,
outcome: Result<loopd::turn::TurnOutcome, TurnError>,
) -> Result<loopd::turn::TurnOutcome, TurnError> {
let mut events = Vec::new();
compact::finish_turn(
session,
&self.runtime(),
&NoMemory,
text,
None,
&mut |e: &TurnEvent| events.push(e.clone()),
outcome,
)
}
}
#[test]
fn usage_under_the_threshold_leaves_the_epoch() {
let w = World::new();
w.server.route(CHAT, vec![sse("ok", 400, 449)]);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "hi", None, &mut |e| {
events.push(e.clone())
});
let outcome = w.finish(&mut s, "hi", outcome).unwrap();
assert_eq!(outcome.content, "ok");
assert_eq!(s.epoch(), Epoch(0));
assert_eq!(w.server.requests_to(CHAT).len(), 1);
}
#[test]
fn usage_at_the_threshold_compacts_before_the_turn_returns() {
let w = World::new();
w.server
.route(CHAT, vec![sse("ok", 400, 450), sse("the sum", 0, 10)]);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "hi", None, &mut |e| {
events.push(e.clone())
});
w.finish(&mut s, "hi", outcome).unwrap();
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(s.carry(), Some("the sum"));
let sent = w.server.requests_to(CHAT);
assert_eq!(sent.len(), 2);
assert_eq!(sent[0].json()["id_slot"], 0);
assert_eq!(sent[1].json()["id_slot"], 1);
assert!(sent[1].json().get("tools").is_none());
let old = w.home.records("a");
assert!(matches!(
old.last(),
Some(LogRecord::EpochEnd { next, .. }) if *next == Epoch(1)
));
}
#[test]
fn session_full_compacts_and_retries_the_user_message_once() {
let w = World::new();
w.server.route(
CHAT,
vec![
Reply::fixture("context_full"),
sse("the sum", 0, 10),
sse("retried", 0, 20),
],
);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "please fit", None, &mut |e| {
events.push(e.clone())
});
assert!(matches!(outcome, Err(TurnError::SessionFull)));
let done = w.finish(&mut s, "please fit", outcome).unwrap();
assert_eq!(done.content, "retried");
assert_eq!(s.epoch(), Epoch(1));
let users: Vec<_> = s
.records()
.iter()
.filter_map(|r| match r {
LogRecord::User { content, .. } => Some(content.as_str()),
_ => None,
})
.collect();
assert_eq!(users, ["please fit"]);
let old_users = w
.home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::User { .. }))
.count();
assert_eq!(old_users, 1);
assert_eq!(s.turns(), 2);
let sent = w.server.requests_to(CHAT);
assert_eq!(sent.len(), 3);
assert_eq!(sent[1].json()["id_slot"], 1);
assert_eq!(sent[2].json()["id_slot"], 0);
}
#[test]
fn session_full_stays_session_full_when_compact_fails() {
let w = World::new();
w.server.route(
CHAT,
vec![
Reply::fixture("context_full"),
Reply::fixture("bad_request"),
],
);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "x", None, &mut |e| {
events.push(e.clone())
});
let err = w.finish(&mut s, "x", outcome).unwrap_err();
assert!(matches!(err, TurnError::SessionFull), "{err:?}");
assert_eq!(s.epoch(), Epoch(0));
}
#[test]
fn a_successful_turn_stays_successful_when_compact_fails() {
let home = Home::new();
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.expect.n_ctx = 1_000;
cfg.compact.threshold = 0.85;
let client = Client::new(cfg.clone());
server.route(CHAT, vec![sse("ok", 400, 450)]);
let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
let mut s = Session::create(
&home.dir,
SessionId::new("a").unwrap(),
baseline,
cfg.slots.main,
)
.unwrap();
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hi", None, &mut |e| events.push(e.clone()));
let done = compact::finish_turn(
&mut s,
&rt,
&NoMemory,
"hi",
None,
&mut |e| events.push(e.clone()),
outcome,
)
.unwrap();
assert_eq!(done.content, "ok");
assert_eq!(s.epoch(), Epoch(0), "compact.md is missing, so no epoch");
}
#[test]
fn finish_turn_does_not_compact_an_error_other_than_session_full() {
let w = World::new();
w.server.route(CHAT, vec![Reply::fixture("bad_request")]);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "x", None, &mut |e| {
events.push(e.clone())
});
let err = w.finish(&mut s, "x", outcome).unwrap_err();
assert!(matches!(err, TurnError::Infer(_)), "{err:?}");
assert_eq!(s.epoch(), Epoch(0));
assert_eq!(w.server.requests_to(CHAT).len(), 1);
}
@@ -0,0 +1,119 @@
//! Tests for `[compact]` on `loopd.toml`. Do not edit.
use loopd::config::Config;
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
fn minimal() -> String {
std::fs::read_to_string(fixture("minimal.toml")).unwrap()
}
#[test]
fn missing_table_gets_the_documented_defaults() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(c.compact.threshold, 0.85);
assert_eq!(c.compact.summary_chars, 4_000);
assert_eq!(c.compact.summarise, vec!["http_fetch".to_string()]);
}
#[test]
fn the_table_overrides_every_default() {
let text = format!(
"{}\n[compact]\nthreshold = 0.5\nsummary_chars = 100\nsummarise = [\"clock\"]\n",
minimal()
);
let c = Config::parse(&text).unwrap();
assert_eq!(c.compact.threshold, 0.5);
assert_eq!(c.compact.summary_chars, 100);
assert_eq!(c.compact.summarise, vec!["clock".to_string()]);
}
#[test]
fn a_partial_table_keeps_the_other_defaults() {
let text = format!("{}\n[compact]\nthreshold = 0.9\n", minimal());
let c = Config::parse(&text).unwrap();
assert_eq!(c.compact.threshold, 0.9);
assert_eq!(c.compact.summary_chars, 4_000);
assert_eq!(c.compact.summarise, vec!["http_fetch".to_string()]);
}
#[test]
fn unknown_keys_are_errors() {
let text = minimal();
let bad = format!("{text}\n[compact]\nzz_unknown = 1\n");
assert!(
Config::parse(&bad).is_err(),
"[compact] accepted an unknown key"
);
let misspelt = format!("{text}\n[compact]\nsummary_char = 10\n");
assert!(
Config::parse(&misspelt).is_err(),
"[compact] accepted a misspelt key"
);
}
#[test]
fn empty_summarise_is_allowed() {
let text = format!("{}\n[compact]\nsummarise = []\n", minimal());
let c = Config::parse(&text).unwrap();
assert!(c.compact.summarise.is_empty());
}
#[test]
fn compact_prompt_is_compact_md_beside_system_md() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(c.compact_prompt(), fixture("compact.md"));
let home = Scratch::new();
let cfg_path = home.dir.join("loopd.toml");
std::fs::write(&cfg_path, minimal()).unwrap();
std::fs::write(home.dir.join("system.md"), "sys\n").unwrap();
let c = Config::load(&cfg_path).unwrap();
assert_eq!(c.compact_prompt(), home.dir.join("compact.md"));
}
struct Scratch {
dir: PathBuf,
}
impl Scratch {
fn new() -> Scratch {
let dir = std::env::temp_dir().join(format!(
"loopd-compact-cfg-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
Scratch { dir }
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn compact_prompt_follows_an_overridden_system_path() {
let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap();
let c = Config::load(&fixture("m2b.toml")).unwrap();
assert!(
text.contains("prompts/agent.md"),
"m2b fixture names a prompt in a subdirectory"
);
assert_eq!(c.compact_prompt(), fixture("prompts/compact.md"));
}
#[test]
fn defaults_match_the_struct_default() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(c.compact, loopd::config::Compact::default());
}
@@ -0,0 +1,255 @@
//! 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);
}
#[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))
);
}
@@ -0,0 +1,181 @@
//! Honcho reads every epoch file of a session, in order. 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::{Epoch, LogRecord, SessionId, Timestamp};
use logs::{assistant, write_log};
use memoryd::honcho::decide::{turn_records, turns};
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
use memoryd::honcho::turns::{LOG_RUNBOOK, TurnSync, read_session};
use tmp::TempDir;
use turns::{clean, sid, spoken};
const LEDGER: &str = "honcho.jsonl";
fn epoch_end(summary: &str) -> LogRecord {
LogRecord::EpochEnd {
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
next: Epoch(1),
summary: summary.to_string(),
}
}
fn write_epoch(home: &std::path::Path, session: &str, epoch: u32, records: &[LogRecord]) {
let dir = home.join("sessions").join(session);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{epoch}.jsonl"));
let text: String = records
.iter()
.map(|r| format!("{}\n", serde_json::to_string(r).unwrap()))
.collect();
std::fs::write(path, text).unwrap();
}
struct World {
sync: TurnSync,
answer: RefCell<Result<bxcore::SessionStateReply, 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 mut ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
ledger
.append(Record::Began {
time: Timestamp::parse("2026-09-28T09:00:00.000Z").unwrap(),
})
.unwrap();
World {
sync: TurnSync::new(ledger, "boxmaker0", &dir.path().join("sessions")),
answer: RefCell::new(Ok(clean())),
dir,
}
}
fn updated(&mut self, session: &str, turn: u64) {
let ask = |s: &SessionId| {
let _ = s;
self.answer.borrow().clone()
};
let log = |_line: &str| {};
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 to(workspace: Workspace, peer: &str) -> Action {
Action::To {
workspace,
peer: peer.to_string(),
}
}
#[test]
fn concatenated_logs_count_three_turns() {
let dir = TempDir::new("honcho-epochs-count");
let home = dir.path();
let epoch0 = vec![
spoken("one", Some("kyle")),
assistant(2, Some("ok"), &[]),
spoken("two", Some("kyle")),
assistant(3, Some("ok"), &[]),
epoch_end("sum"),
];
write_log(home, "mm-a", &epoch0);
write_epoch(
home,
"mm-a",
1,
&[
spoken("three", Some("kyle")),
assistant(4, Some("ok"), &[]),
],
);
let records = read_session(&home.join("sessions"), &sid("mm-a")).unwrap();
assert_eq!(turns(&records), 3);
let turn3 = turn_records(&records, 3).unwrap();
assert!(
matches!(turn3[0], LogRecord::User { ref content, .. } if content == "three"),
"{turn3:?}"
);
}
#[test]
fn epoch_0_alone_still_reads() {
let dir = TempDir::new("honcho-epochs-zero");
let home = dir.path();
write_log(home, "mm-a", &[spoken("only", Some("kyle"))]);
let records = read_session(&home.join("sessions"), &sid("mm-a")).unwrap();
assert_eq!(turns(&records), 1);
}
#[test]
fn a_damaged_later_epoch_is_an_error_naming_that_file() {
let dir = TempDir::new("honcho-epochs-damaged");
let home = dir.path();
write_log(home, "mm-a", &[spoken("one", Some("kyle"))]);
let path = home.join("sessions/mm-a/1.jsonl");
std::fs::write(&path, "{\"type\":\"zz\"}\n").unwrap();
let err = read_session(&home.join("sessions"), &sid("mm-a")).unwrap_err();
assert!(err.to_string().contains("1.jsonl"), "{err}");
assert!(
err.to_string().contains(LOG_RUNBOOK) || err.to_string().contains("session-log-damaged"),
"{err}"
);
}
#[test]
fn turn_sync_decides_a_turn_that_lives_in_epoch_1() {
let mut w = World::new("honcho-epochs-sync");
write_log(
w.dir.path(),
"mm-a",
&[
spoken("one", Some("kyle")),
assistant(2, Some("ok"), &[]),
spoken("two", Some("kyle")),
assistant(3, Some("ok"), &[]),
epoch_end("sum"),
],
);
write_epoch(
w.dir.path(),
"mm-a",
1,
&[
spoken("three", Some("kyle")),
assistant(4, Some("ok"), &[]),
],
);
w.updated("mm-a", 3);
assert_eq!(w.action("mm-a", 3), Some(to(Workspace::Trusted, "kyle")));
}
#[test]
fn a_missing_session_directory_is_empty() {
let dir = TempDir::new("honcho-epochs-missing");
let records = read_session(&dir.path().join("sessions"), &sid("mm-none")).unwrap();
assert!(records.is_empty());
}