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:
@@ -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: ®istry,
|
||||
};
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user