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,4 @@
It is time for a heartbeat.
Look at recent notes and the current identity. Write a note only if something needs the owner, or
if a standing fact should move. Do not use tools that fetch. Do not address the owner.
@@ -0,0 +1,6 @@
# Copy to {BOXMAKER_HOME}/jobs/tier.toml and edit the schedule.
# Five-field UTC crontab. This example is Sunday 07:00.
schedule = "0 7 * * 0"
prompt = """Read the listing in this message. Promote only trusted notes into identity
(memory.md / user.md) with add or consolidate. Do not promote an untrusted note.
Do not fetch. Write a note of what you changed, or that nothing needed changing."""
@@ -0,0 +1,2 @@
Summarise this tool result for later recall. Keep facts, names, numbers and URLs. Drop boilerplate
and navigation. Do not use tools. Do not address the owner. Do not invent facts.
@@ -0,0 +1,52 @@
//! Tests for `LogRecord::ToolSummary`. Do not edit.
use bxcore::{CallId, DataClass, LogRecord, Timestamp};
fn ts() -> Timestamp {
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
#[test]
fn tool_summary_round_trips() {
let rec = LogRecord::ToolSummary {
time: ts(),
call: CallId(3),
tool_call_id: "call_x".to_string(),
content: "the page is about tea".to_string(),
class: DataClass::Public,
untrusted: true,
};
let line = serde_json::to_string(&rec).unwrap();
assert!(line.contains("\"type\":\"tool_summary\""), "{line}");
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back, rec);
}
#[test]
fn unknown_fields_are_refused() {
let line = r#"{"type":"tool_summary","time":"2026-09-18T08:00:00.000Z","call":3,"tool_call_id":"c","content":"x","class":"public","untrusted":false,"extra":1}"#;
assert!(serde_json::from_str::<LogRecord>(line).is_err());
}
#[test]
fn field_order_is_the_struct_order() {
let rec = LogRecord::ToolSummary {
time: ts(),
call: CallId(1),
tool_call_id: "c".to_string(),
content: "x".to_string(),
class: DataClass::Private,
untrusted: false,
};
let line = serde_json::to_string(&rec).unwrap();
let time = line.find("\"time\"").unwrap();
let call = line.find("\"call\"").unwrap();
let id = line.find("\"tool_call_id\"").unwrap();
let content = line.find("\"content\"").unwrap();
let class = line.find("\"class\"").unwrap();
let untrusted = line.find("\"untrusted\"").unwrap();
assert!(
time < call && call < id && id < content && content < class && class < untrusted,
"{line}"
);
}
@@ -0,0 +1,86 @@
//! Tests for `bxctl memory tiers`. Do not edit.
#[path = "support/temp.rs"]
mod temp;
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use bxcore::{Envelope, MemoryAdminReply, MemoryCommand, Message, read_frame, write_frame};
use bxctl::cli::{Command, UsageError, parse};
use bxctl::memory::{Ask, run};
fn args(line: &str) -> Vec<String> {
line.split_whitespace().map(str::to_string).collect()
}
fn home() -> PathBuf {
PathBuf::from("/srv/bx")
}
fn fake(socket: &Path, answer: Message) -> mpsc::Receiver<Message> {
let listener = UnixListener::bind(socket).unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let env = read_frame(&mut stream).unwrap();
tx.send(env.msg).unwrap();
let reply = Envelope {
v: 1,
id: env.id,
r#final: true,
msg: answer,
};
write_frame(&mut stream, &reply).unwrap();
});
rx
}
#[test]
fn memory_tiers_parses() {
let h = home();
assert_eq!(
parse(&args("memory tiers"), &h),
Ok(Command::Memory {
socket: PathBuf::from("/srv/bx/run/owner-memory/admin.sock"),
ask: Ask::Admin(MemoryCommand::Tiers, String::new())
})
);
assert_eq!(
parse(&args("memory --memory-socket /r/m.sock tiers"), &h),
Ok(Command::Memory {
socket: PathBuf::from("/r/m.sock"),
ask: Ask::Admin(MemoryCommand::Tiers, String::new())
})
);
assert_eq!(parse(&args("memory tiers extra"), &h), Err(UsageError));
}
#[test]
fn memory_tiers_prints_the_reply() {
let d = temp::TempDir::new("bx-tiers");
let socket = d.join("admin.sock");
let listing = "identity memory.md hits 0 last -\n I like tea.\n";
let seen = fake(
&socket,
Message::MemoryAdminReply(MemoryAdminReply {
text: listing.to_string(),
}),
);
let mut out = Vec::new();
run(
&socket,
&Ask::Admin(MemoryCommand::Tiers, String::new()),
Duration::from_secs(5),
&mut out,
)
.unwrap();
assert_eq!(String::from_utf8(out).unwrap(), format!("{listing}\n"));
match seen.recv().unwrap() {
Message::MemoryAdmin(m) => {
assert_eq!((m.command, m.arg.as_str()), (MemoryCommand::Tiers, ""))
}
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,110 @@
//! Tests for `[heartbeat]` and unknown `[compact] summarise` names. Do not edit.
use loopd::config::Config;
use loopd::tools::Registry;
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()
}
struct Scratch {
dir: PathBuf,
}
impl Scratch {
fn new() -> Scratch {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("loopd-hb-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
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 missing_heartbeat_table_is_disabled() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(c.heartbeat.every_s, 0);
assert_eq!(
c.heartbeat_prompt(),
fixture("minimal.toml")
.parent()
.unwrap()
.join("heartbeat.md")
);
}
#[test]
fn the_table_overrides_every_default() {
let text = format!(
"{}\n[heartbeat]\nevery_s = 60\nprompt = \"pulse.md\"\n",
minimal()
);
let c = Config::parse(&text).unwrap();
assert_eq!(c.heartbeat.every_s, 60);
assert_eq!(c.heartbeat.prompt, PathBuf::from("pulse.md"));
}
#[test]
fn unknown_keys_are_errors() {
let bad = format!("{}\n[heartbeat]\nzz_unknown = 1\n", minimal());
assert!(Config::parse(&bad).is_err());
}
#[test]
fn heartbeat_prompt_is_beside_the_config_file() {
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.heartbeat_prompt(), home.dir.join("heartbeat.md"));
}
#[test]
fn default_summarise_http_fetch_is_known_to_m5c() {
let c = Config::parse(&minimal()).unwrap();
c.check_summarise(&Registry::m5c()).unwrap();
}
#[test]
fn an_unknown_summarise_name_is_an_error() {
let text = format!("{}\n[compact]\nsummarise = [\"no_such_tool\"]\n", minimal());
let c = Config::parse(&text).unwrap();
let err = c.check_summarise(&Registry::m5c()).unwrap_err();
assert!(err.contains("no_such_tool"), "{err}");
assert!(err.contains("summarise"), "{err}");
}
#[test]
fn empty_summarise_is_allowed() {
let text = format!("{}\n[compact]\nsummarise = []\n", minimal());
let c = Config::parse(&text).unwrap();
c.check_summarise(&Registry::m5c()).unwrap();
}
#[test]
fn http_fetch_is_known_and_clock_is_known() {
let text = format!(
"{}\n[compact]\nsummarise = [\"http_fetch\", \"clock\"]\n",
minimal()
);
let c = Config::parse(&text).unwrap();
c.check_summarise(&Registry::m5c()).unwrap();
}
@@ -0,0 +1,85 @@
//! Tests for the five-field UTC cron matcher. Do not edit.
use bxcore::Timestamp;
use loopd::cron::{self, CronError};
fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn due(schedule: &str, at: &str) -> bool {
cron::due(schedule, ts(at)).unwrap()
}
#[test]
fn a_daily_hour_matches_that_minute_only() {
assert!(due("0 7 * * *", "2026-10-03T07:00:00.000Z"));
assert!(!due("0 7 * * *", "2026-10-03T07:01:00.000Z"));
assert!(!due("0 7 * * *", "2026-10-03T06:00:00.000Z"));
}
#[test]
fn every_minute_matches() {
assert!(due("* * * * *", "2026-10-03T00:00:00.000Z"));
assert!(due("* * * * *", "2026-10-03T23:59:00.000Z"));
}
#[test]
fn a_step_matches_multiples_from_zero() {
assert!(due("*/5 * * * *", "2026-10-03T00:00:00.000Z"));
assert!(due("*/5 * * * *", "2026-10-03T00:05:00.000Z"));
assert!(!due("*/5 * * * *", "2026-10-03T00:06:00.000Z"));
}
#[test]
fn a_range_is_inclusive() {
assert!(due("0-10 * * * *", "2026-10-03T00:00:00.000Z"));
assert!(due("0-10 * * * *", "2026-10-03T00:10:00.000Z"));
assert!(!due("0-10 * * * *", "2026-10-03T00:11:00.000Z"));
}
#[test]
fn sunday_is_zero() {
// 2026-10-04 is a Sunday.
assert!(due("0 7 * * 0", "2026-10-04T07:00:00.000Z"));
// 2026-10-03 is a Saturday.
assert!(!due("0 7 * * 0", "2026-10-03T07:00:00.000Z"));
}
#[test]
fn the_first_of_the_month() {
assert!(due("0 7 1 * *", "2026-10-01T07:00:00.000Z"));
assert!(!due("0 7 1 * *", "2026-10-02T07:00:00.000Z"));
}
#[test]
fn four_or_six_fields_are_errors() {
assert!(matches!(
cron::due("* * * *", ts("2026-10-03T00:00:00.000Z")),
Err(CronError::Fields(_))
));
assert!(matches!(
cron::due("* * * * * *", ts("2026-10-03T00:00:00.000Z")),
Err(CronError::Fields(_))
));
}
#[test]
fn an_out_of_range_number_is_an_error() {
assert!(cron::due("60 * * * *", ts("2026-10-03T00:00:00.000Z")).is_err());
assert!(cron::due("* 24 * * *", ts("2026-10-03T00:00:00.000Z")).is_err());
assert!(cron::due("* * * * 7", ts("2026-10-03T00:00:00.000Z")).is_err());
}
#[test]
fn nicknames_are_errors() {
let err = cron::due("@daily", ts("2026-10-03T00:00:00.000Z")).unwrap_err();
assert!(matches!(err, CronError::Fields(_)), "{err:?}");
}
#[test]
fn display_names_the_schedule() {
let err = cron::due("x x x x x", ts("2026-10-03T00:00:00.000Z")).unwrap_err();
let s = err.to_string();
assert!(s.contains("x x x x x"), "{s}");
}
@@ -0,0 +1,151 @@
//! Tests for job files and the run ledger. Do not edit.
use bxcore::{SessionId, Timestamp};
use loopd::jobs::{self, JobsError, RunStatus};
fn ts() -> Timestamp {
Timestamp::parse("2026-10-03T07:00:00.000Z").unwrap()
}
struct Scratch {
dir: std::path::PathBuf,
}
impl Scratch {
fn new() -> Scratch {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("loopd-jobs-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
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 a_valid_job_file_loads() {
let home = Scratch::new();
let jobs = home.dir.join("jobs");
std::fs::create_dir(&jobs).unwrap();
std::fs::write(
jobs.join("mail.toml"),
"schedule = \"0 7 * * *\"\nprompt = \"Check mail.\"\n",
)
.unwrap();
let loaded = jobs::load(&jobs).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "mail");
assert_eq!(loaded[0].schedule, "0 7 * * *");
assert_eq!(loaded[0].prompt, "Check mail.");
}
#[test]
fn unknown_keys_are_errors_with_the_runbook() {
let home = Scratch::new();
let jobs = home.dir.join("jobs");
std::fs::create_dir(&jobs).unwrap();
std::fs::write(
jobs.join("mail.toml"),
"schedule = \"* * * * *\"\nprompt = \"x\"\nwhen = \"now\"\n",
)
.unwrap();
let err = jobs::load(&jobs).unwrap_err();
let s = err.to_string();
assert!(s.contains("mail.toml"), "{s}");
assert!(s.ends_with("see docs/runbook.md#jobs-invalid"), "{s}");
}
#[test]
fn a_bad_name_is_an_error() {
let home = Scratch::new();
let jobs = home.dir.join("jobs");
std::fs::create_dir(&jobs).unwrap();
std::fs::write(
jobs.join("Mail.toml"),
"schedule = \"* * * * *\"\nprompt = \"x\"\n",
)
.unwrap();
let err = jobs::load(&jobs).unwrap_err();
assert!(matches!(err, JobsError::Name(_)), "{err:?}");
}
#[test]
fn session_id_fits_and_is_lowercase() {
let id = jobs::session_id("mail", ts()).unwrap();
assert_eq!(id.as_str(), "job-mail-20261003t0700");
SessionId::new(id.as_str()).unwrap();
}
#[test]
fn a_name_too_long_for_a_session_id_is_an_error() {
let long = "n".repeat(50);
let err = jobs::session_id(&long, ts()).unwrap_err();
assert!(matches!(err, JobsError::Name(_)), "{err:?}");
}
#[test]
fn a_missing_jobs_directory_is_empty() {
let home = Scratch::new();
let loaded = jobs::load(&home.dir.join("jobs")).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn leftover_started_is_closed_as_failed() {
let home = Scratch::new();
let path = home.dir.join("runs.jsonl");
let session = jobs::session_id("mail", ts()).unwrap();
jobs::append(
&path,
&jobs::Run {
time: ts(),
name: "mail".to_string(),
session: session.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let n = jobs::close_leftovers(&path, ts()).unwrap();
assert_eq!(n, 1);
assert!(jobs::live_session(&path, "mail").unwrap().is_none());
}
#[test]
fn a_started_run_without_a_close_is_live() {
let home = Scratch::new();
let path = home.dir.join("runs.jsonl");
let session = jobs::session_id("mail", ts()).unwrap();
jobs::append(
&path,
&jobs::Run {
time: ts(),
name: "mail".to_string(),
session: session.clone(),
status: RunStatus::Started,
},
)
.unwrap();
assert_eq!(
jobs::live_session(&path, "mail").unwrap().as_ref(),
Some(&session)
);
jobs::append(
&path,
&jobs::Run {
time: ts(),
name: "mail".to_string(),
session,
status: RunStatus::Finished,
},
)
.unwrap();
assert!(jobs::live_session(&path, "mail").unwrap().is_none());
}
@@ -0,0 +1,186 @@
//! Tests for the cron tick. Do not edit.
mod support;
use bxcore::{SessionId, Timestamp};
use loopd::channel::Context;
use loopd::jobs::{self, RunStatus};
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::scheduler::{Scheduler, TickWork};
use loopd::tools::Registry;
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"done"}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"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 ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn world(schedule: &str) -> (Home, FakeServer, Arc<Context>) {
let home = Home::new();
home.write("compact.md", "Summarise.\n");
home.write(
"jobs/mail.toml",
&format!("schedule = \"{schedule}\"\nprompt = \"Check mail.\"\n"),
);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse(), sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
(home, server, ctx)
}
#[test]
fn a_due_job_starts_a_new_session_on_the_background_slot() {
let (home, server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let want = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
assert_eq!(
work,
vec![TickWork::Job {
name: "mail".to_string(),
session: want.clone()
}]
);
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
let last = sent[0].json()["messages"]
.as_array()
.unwrap()
.last()
.unwrap();
let content = last["content"].as_str().unwrap();
assert!(
content.starts_with("It is 2026-10-03T07:00:00.000Z."),
"{content}"
);
assert!(content.contains("Check mail."), "{content}");
let s = loopd::session::Session::open(&home.dir, want).unwrap();
assert_eq!(s.slot(), 1);
}
#[test]
fn a_job_not_due_does_not_run() {
let (_home, server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
assert!(work.is_empty());
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn a_live_previous_run_skips_the_tick() {
let (home, server, ctx) = world("* * * * *");
let ledger = home.dir.join("jobs/runs.jsonl");
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
jobs::append(
&ledger,
&jobs::Run {
time: ts("2026-10-03T06:59:00.000Z"),
name: "mail".to_string(),
session: previous.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let held = ctx.hold(previous).expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(
work,
vec![TickWork::SkippedJob {
name: "mail".to_string()
}]
);
assert!(server.requests_to(CHAT).is_empty());
drop(held);
}
#[test]
fn leftover_started_is_failed_on_the_first_tick() {
let (home, _server, ctx) = world("* * * * *");
let ledger = home.dir.join("jobs/runs.jsonl");
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
jobs::append(
&ledger,
&jobs::Run {
time: ts("2026-10-03T06:59:00.000Z"),
name: "mail".to_string(),
session: previous.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let text = home.read("jobs/runs.jsonl");
assert!(
text.contains("failed"),
"leftover started was closed: {text}"
);
assert!(text.contains(previous.as_str()), "{text}");
}
#[test]
fn a_cron_turn_has_no_speaker() {
let (home, _server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let id = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
let recs = {
let text = home.read(&format!("sessions/{}/0.jsonl", id.as_str()));
text.lines()
.map(|l| serde_json::from_str::<bxcore::LogRecord>(l).unwrap())
.collect::<Vec<_>>()
};
let user = recs
.iter()
.find_map(|r| match r {
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
_ => None,
})
.unwrap();
assert_eq!(user, &None);
}
#[test]
fn two_due_minutes_make_two_sessions() {
let (home, server, ctx) = world("* * * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:58:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(server.requests_to(CHAT).len(), 2);
let a = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
let b = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
assert_ne!(a, b);
assert!(
home.dir
.join(format!("sessions/{}/0.jsonl", a.as_str()))
.is_file()
);
assert!(
home.dir
.join(format!("sessions/{}/0.jsonl", b.as_str()))
.is_file()
);
}
@@ -0,0 +1,148 @@
//! Tests for the heartbeat tick. Do not edit.
mod support;
use bxcore::{SessionId, Timestamp};
use loopd::channel::Context;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::scheduler::{self, Scheduler, TickWork};
use loopd::tools::Registry;
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
const PROMPT: &str = "Look around.\n";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"noted"}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"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 ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn world(every_s: u64) -> (Home, FakeServer, Arc<Context>) {
let home = Home::new();
home.write("heartbeat.md", PROMPT);
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.heartbeat.every_s = every_s;
cfg.heartbeat.prompt = home.dir.join("heartbeat.md");
server.route(CHAT, vec![sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
(home, server, ctx)
}
#[test]
fn every_s_zero_does_nothing() {
let (_home, server, ctx) = world(0);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T08:00:00.000Z"));
assert!(work.is_empty());
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn a_due_heartbeat_runs_on_the_background_slot_with_a_time_line() {
let (home, server, ctx) = world(60);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert_eq!(work, vec![TickWork::Heartbeat]);
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
let last = sent[0].json()["messages"]
.as_array()
.unwrap()
.last()
.unwrap();
assert_eq!(last["role"], "user");
let content = last["content"].as_str().unwrap();
assert!(
content.starts_with("It is 2026-10-03T07:01:00.000Z."),
"{content}"
);
assert!(content.contains("Look around."), "{content}");
let s = loopd::session::Session::open(&home.dir, SessionId::new("heartbeat").unwrap()).unwrap();
assert_eq!(s.slot(), 1);
}
#[test]
fn a_second_tick_before_every_s_does_not_run() {
let (_home, server, ctx) = world(60);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:30.000Z"));
assert!(work.is_empty());
assert_eq!(server.requests_to(CHAT).len(), 1);
}
#[test]
fn a_busy_heartbeat_session_is_skipped() {
let (_home, server, ctx) = world(60);
let held = ctx
.hold(SessionId::new("heartbeat").unwrap())
.expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert_eq!(
work,
vec![TickWork::SkippedBusy {
session: SessionId::new("heartbeat").unwrap()
}]
);
assert!(server.requests_to(CHAT).is_empty());
drop(held);
}
#[test]
fn an_unreadable_prompt_is_skipped_and_names_the_runbook() {
let home = Home::new();
let path = home.dir.join("heartbeat.md");
std::fs::create_dir(&path).unwrap();
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.heartbeat.every_s = 60;
cfg.heartbeat.prompt = path;
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert_eq!(work, vec![TickWork::HeartbeatUnreadable]);
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn heartbeat_has_no_speaker() {
let (home, _server, ctx) = world(60);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
let recs = home.records("heartbeat");
let user = recs
.iter()
.find_map(|r| match r {
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
_ => None,
})
.unwrap();
assert_eq!(user, &None);
}
@@ -0,0 +1,170 @@
//! Tests that a live turn uses the session's slot. Do not edit.
mod support;
use bxcore::SessionId;
use loopd::baseline::Baseline;
use loopd::llama::Client;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use support::{FakeServer, Home, Reply};
const CHAT: &str = "/v1/chat/completions";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"ok"}}],"id":"t","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"t","timings":{"cache_n":0,"prompt_n":10,"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 runtime<'a>(
cfg: &'a loopd::config::Config,
client: &'a Client,
port: &'a support::ScriptedPort,
registry: &'a Registry,
) -> Runtime<'a> {
Runtime {
cfg,
client,
port,
registry,
}
}
#[test]
fn a_session_created_on_the_background_slot_sends_that_slot() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let mut s = Session::create(
&home.dir,
SessionId::new("heartbeat").unwrap(),
baseline,
cfg.slots.background,
)
.unwrap();
let mut events = Vec::new();
run_turn(
&mut s,
&runtime(&cfg, &client, &port, &registry),
"pulse",
None,
&mut |e| events.push(e.clone()),
)
.unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], cfg.slots.background);
}
#[test]
fn a_main_slot_session_still_sends_main() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let mut s = Session::create(
&home.dir,
SessionId::new("mm-thread").unwrap(),
baseline,
cfg.slots.main,
)
.unwrap();
let mut events = Vec::new();
run_turn(
&mut s,
&runtime(&cfg, &client, &port, &registry),
"hi",
None,
&mut |e| events.push(e.clone()),
)
.unwrap();
assert_eq!(
server.requests_to(CHAT)[0].json()["id_slot"],
cfg.slots.main
);
}
#[test]
fn the_request_slot_follows_session_start_not_the_config_main() {
let home = Home::new();
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.slots.main = 0;
cfg.slots.background = 1;
server.route(CHAT, vec![sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let mut s = Session::create(&home.dir, SessionId::new("a").unwrap(), baseline, 7).unwrap();
let mut events = Vec::new();
run_turn(
&mut s,
&runtime(&cfg, &client, &port, &registry),
"x",
None,
&mut |e| events.push(e.clone()),
)
.unwrap();
assert_eq!(server.requests_to(CHAT)[0].json()["id_slot"], 7);
}
#[test]
fn a_heartbeat_turn_between_two_main_turns_does_not_change_the_main_prefix() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse(), sse(), sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let mut main = Session::create(
&home.dir,
SessionId::new("mm-a").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap();
let mut beat = Session::create(
&home.dir,
SessionId::new("heartbeat").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.background,
)
.unwrap();
let rt = runtime(&cfg, &client, &port, &registry);
let mut events = Vec::new();
run_turn(&mut main, &rt, "one", None, &mut |e| events.push(e.clone())).unwrap();
run_turn(&mut beat, &rt, "pulse", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
run_turn(&mut main, &rt, "two", None, &mut |e| events.push(e.clone())).unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 3);
assert_eq!(sent[0].json()["id_slot"], 0);
assert_eq!(sent[1].json()["id_slot"], 1);
assert_eq!(sent[2].json()["id_slot"], 0);
let first = sent[0].json()["messages"].as_array().unwrap().clone();
let third = sent[2].json()["messages"].as_array().unwrap();
assert!(
third.len() > first.len(),
"the main session's second turn is a longer prefix"
);
assert_eq!(&third[..first.len()], &first[..]);
}
@@ -0,0 +1,98 @@
//! A turn-loop setup shared by the `turn` and `limits` tests: a home, a fake server, a scripted
//! port and a registry. Included with `#[path]` because it needs `loopd::turn`, which the earlier
//! tasks' tests must not depend on. Do not edit.
#![allow(dead_code)]
use crate::support::{FakeServer, Home, ScriptedPort};
use bxcore::{LogRecord, SessionId, ToolResponse, TurnEvent};
use loopd::baseline::Baseline;
use loopd::llama::Client;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, TurnError, run_turn};
pub struct Setup {
pub home: Home,
pub server: FakeServer,
pub cfg: loopd::config::Config,
pub client: Client,
pub port: ScriptedPort,
pub registry: Registry,
}
pub fn setup(replies: Vec<ToolResponse>) -> Setup {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
let client = Client::new(cfg.clone());
Setup {
home,
server,
cfg,
client,
port: ScriptedPort::new(replies),
registry: Registry::m2b(),
}
}
impl Setup {
pub fn session(&self, id: &str) -> Session {
let baseline = Baseline::assemble(&self.cfg, &self.registry).unwrap();
Session::create(
&self.home.dir,
SessionId::new(id).unwrap(),
baseline,
self.cfg.slots.main,
)
.unwrap()
}
pub fn runtime(&self) -> Runtime<'_> {
Runtime {
cfg: &self.cfg,
client: &self.client,
port: &self.port,
registry: &self.registry,
}
}
pub fn turn(
&self,
session: &mut Session,
text: &str,
) -> (Result<loopd::turn::TurnOutcome, TurnError>, Vec<TurnEvent>) {
let mut events = Vec::new();
let result = run_turn(session, &self.runtime(), text, None, &mut |e| {
events.push(e.clone())
});
(result, events)
}
}
/// The recordings were made in separate conversations, so their timings do not line up and the
/// loop rightly reports cache losses between them. Most tests are not about that, so `types`
/// and `records` leave `CacheLoss` out; one test checks it on purpose.
pub fn without_cache_loss(records: &[LogRecord]) -> Vec<LogRecord> {
records
.iter()
.filter(|r| !matches!(r, LogRecord::CacheLoss { .. }))
.cloned()
.collect()
}
pub fn types(records: &[LogRecord]) -> Vec<&'static str> {
without_cache_loss(records)
.iter()
.map(|r| match r {
LogRecord::SessionStart { .. } => "start",
LogRecord::User { .. } => "user",
LogRecord::Assistant { .. } => "assistant",
LogRecord::Usage { .. } => "usage",
LogRecord::ToolResult { .. } => "tool_result",
LogRecord::ToolSummary { .. } => "tool_summary",
LogRecord::CacheLoss { .. } => "cache_loss",
LogRecord::EpochEnd { .. } => "epoch_end",
})
.collect()
}
@@ -0,0 +1,241 @@
//! Tests for the opted-in tool-output summary. Do not edit.
mod support;
use bxcore::{CallId, DataClass, LogRecord, SessionId, ToolCall};
use loopd::baseline::Baseline;
use loopd::compact;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::session::Session;
use loopd::summarise;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
const SUMMARISE: &str = "Summarise this tool result.\n";
fn sse(content: &str) -> 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":"s","timings":{{"cache_n":0,"prompt_n":10,"predicted_n":1}}}}"#
);
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"s","timings":{"cache_n":0,"prompt_n":10,"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 ts() -> bxcore::Timestamp {
bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
fn assistant(name: &str) -> LogRecord {
LogRecord::Assistant {
time: ts(),
content: None,
reasoning_content: None,
tool_calls: vec![ToolCall {
id: "call_f".to_string(),
name: name.to_string(),
arguments: "{}".to_string(),
}],
}
}
fn result(untrusted: bool) -> LogRecord {
LogRecord::ToolResult {
time: ts(),
call: CallId(1),
tool_call_id: "call_f".to_string(),
content: "the fetched page is about tea".to_string(),
class: DataClass::Public,
untrusted,
truncated: false,
}
}
fn session(home: &Home, cfg: &loopd::config::Config) -> Session {
let registry = Registry::m2b();
Session::create(
&home.dir,
SessionId::new("a").unwrap(),
Baseline::assemble(cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap()
}
#[test]
fn an_opted_in_result_gets_a_summary_on_the_background_slot() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("tea page")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
assert!(sent[0].json().get("tools").is_none());
assert!(
s.records().iter().any(|r| matches!(r, LogRecord::ToolSummary { content, call, untrusted, .. } if content == "tea page" && *call == CallId(1) && !*untrusted)),
"{:?}",
s.records()
);
}
#[test]
fn the_summary_copies_the_result_labels() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("x")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(true)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
match s.records().last() {
Some(LogRecord::ToolSummary {
class,
untrusted,
tool_call_id,
..
}) => {
assert_eq!(*class, DataClass::Public);
assert!(*untrusted);
assert_eq!(tool_call_id, "call_f");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_tool_not_in_summarise_is_left_alone() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("clock")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
assert!(server.requests_to(CHAT).is_empty());
assert!(
!s.records()
.iter()
.any(|r| matches!(r, LogRecord::ToolSummary { .. }))
);
}
#[test]
fn a_result_that_already_has_a_summary_is_not_summarised_again() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("once")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
assert_eq!(server.requests_to(CHAT).len(), 1);
assert_eq!(
s.records()
.iter()
.filter(|r| matches!(r, LogRecord::ToolSummary { .. }))
.count(),
1
);
}
#[test]
fn messages_stay_the_same_after_a_summary() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("tea page")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
let before = loopd::baseline::messages(s.baseline(), s.records());
summarise::missing(&cfg, &client, &mut s).unwrap();
let after = loopd::baseline::messages(s.baseline(), s.records());
assert_eq!(before, after);
}
#[test]
fn after_a_turn_an_opted_in_call_is_summarised() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["read_file".to_string()];
server.route(
CHAT,
vec![
Reply::fixture("tool_call"),
Reply::fixture("plain"),
sse("hosts file"),
],
);
let client = Client::new(cfg.clone());
let port = ScriptedPort::new(vec![support::ok_result("127.0.0.1 localhost\n")]);
let registry = Registry::m2b();
let mut s = Session::create(
&home.dir,
SessionId::new("a").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap();
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));
compact::finish_turn(
&mut s,
&rt,
&NoMemory,
"hosts?",
None,
&mut |e| events.push(e.clone()),
outcome,
)
.unwrap();
assert!(
s.records()
.iter()
.any(|r| matches!(r, LogRecord::ToolSummary { .. })),
"{:?}",
s.records()
);
assert_eq!(
server.requests_to(CHAT).last().unwrap().json()["id_slot"],
1
);
}
@@ -0,0 +1,88 @@
//! Tests that `messages()` ignores `ToolSummary`. Do not edit.
use bxcore::{CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, Timestamp};
use loopd::baseline::{Baseline, messages};
use loopd::llama::ChatMessage;
use loopd::llama::ToolSchema;
fn ts() -> Timestamp {
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
fn baseline() -> Baseline {
Baseline {
system: "sys".to_string(),
tools: vec![ToolSchema {
name: "clock".to_string(),
description: "t".to_string(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
}],
}
}
fn user(content: &str) -> LogRecord {
LogRecord::User {
time: ts(),
content: content.to_string(),
speaker: None,
}
}
fn result() -> LogRecord {
LogRecord::ToolResult {
time: ts(),
call: CallId(1),
tool_call_id: "c".to_string(),
content: "raw body".to_string(),
class: DataClass::Public,
untrusted: false,
truncated: false,
}
}
fn summary() -> LogRecord {
LogRecord::ToolSummary {
time: ts(),
call: CallId(1),
tool_call_id: "c".to_string(),
content: "a short account".to_string(),
class: DataClass::Public,
untrusted: false,
}
}
#[test]
fn messages_ignore_tool_summary() {
let start = LogRecord::SessionStart {
time: ts(),
session: SessionId::new("a").unwrap(),
epoch: Epoch(0),
slot: 0,
baseline: Hash32::ZERO,
};
let without = messages(&baseline(), &[start.clone(), user("hi"), result()]);
let with = messages(&baseline(), &[start, user("hi"), result(), summary()]);
assert_eq!(without, with);
assert!(
!with
.iter()
.any(|m| matches!(m, ChatMessage::User { content } if content == "a short account")),
"{with:?}"
);
}
#[test]
fn a_summary_is_not_replayed_as_a_tool_message() {
let msgs = messages(&baseline(), &[summary()]);
assert_eq!(msgs.len(), 1, "only the system message");
assert!(matches!(msgs[0], ChatMessage::System { .. }));
}
#[test]
fn inserting_a_summary_does_not_break_prefix_extension() {
let a = vec![user("hi"), result()];
let b = vec![user("hi"), result(), summary(), user("next")];
let ma = messages(&baseline(), &a);
let mb = messages(&baseline(), &b);
assert_eq!(&mb[..ma.len()], &ma[..]);
}
@@ -0,0 +1,122 @@
//! Tests for `MemoryCommand::Tiers`: identity entries and notes with recall-hit counts. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use bxcore::{DataClass, MemoryFile, Timestamp};
use memoryd::files::Store;
use memoryd::tiers;
use tmp::TempDir;
fn now() -> Timestamp {
Timestamp::parse("2026-10-03T08:00:00.000Z").unwrap()
}
struct Setup {
dir: TempDir,
store: Store,
}
fn setup(tag: &str) -> Setup {
let dir = TempDir::new(tag);
let starter = dir.write("etc/soul.md", "Soul.\n");
let store = Store::new(&dir.path().join("memory"));
store.init(&starter).unwrap();
Setup { dir, store }
}
fn write_recall(store: &Store, line: &str) {
std::fs::create_dir_all(store.path("recalls")).unwrap();
store
.append("recalls/2026-10.jsonl", &format!("{line}\n"))
.unwrap();
}
#[test]
fn identity_entries_list_with_zero_hits() {
let s = setup("tiers-id");
s.store
.write_entries(MemoryFile::Memory, &["I like tea.".to_string()])
.unwrap();
s.store
.write_entries(MemoryFile::User, &["The owner is Kyle.".to_string()])
.unwrap();
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains("identity memory.md hits 0 last -"),
"{text}"
);
assert!(text.contains("I like tea."), "{text}");
assert!(text.contains("identity user.md hits 0 last -"), "{text}");
assert!(text.contains("The owner is Kyle."), "{text}");
}
#[test]
fn notes_carry_counts_from_recalls() {
let s = setup("tiers-notes");
std::fs::write(
s.store.path("notes.md"),
"§ noted 2026-09-22T18:20Z in session mm-a · private\nlaunch window moved.\n",
)
.unwrap();
write_recall(
&s.store,
r#"{"time":"2026-10-03T07:00:00.000Z","session":"mm-now","query":"launch","hits":[{"kind":"noted","session":"mm-a","epoch":0,"record":0}]}"#,
);
write_recall(
&s.store,
r#"{"time":"2026-10-03T07:30:00.000Z","session":"mm-now","query":"window","hits":[{"kind":"noted","session":"mm-a","epoch":0,"record":0}]}"#,
);
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains(
"note session mm-a hits 2 last 2026-10-03T07:30:00.000Z private trusted"
),
"{text}"
);
assert!(text.contains("launch window moved."), "{text}");
}
#[test]
fn untrusted_notes_are_listed_and_marked() {
let s = setup("tiers-untrusted");
std::fs::write(
s.store.path("notes.md"),
"§ noted 2026-09-22T18:20Z in session mm-c · private · untrusted\nexample.org says the launch moved.\n",
)
.unwrap();
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains("note session mm-c hits 0 last - private untrusted"),
"{text}"
);
assert!(
text.contains("example.org says the launch moved."),
"{text}"
);
}
#[test]
fn a_recall_of_a_different_note_does_not_count() {
let s = setup("tiers-other");
std::fs::write(
s.store.path("notes.md"),
"§ noted 2026-09-22T18:20Z in session mm-a · private\nfirst note.\n§ noted 2026-09-22T18:21Z in session mm-b · private\nsecond note.\n",
)
.unwrap();
write_recall(
&s.store,
r#"{"time":"2026-10-03T07:00:00.000Z","session":"mm-now","query":"second","hits":[{"kind":"noted","session":"mm-b","epoch":0,"record":1}]}"#,
);
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains("note session mm-a hits 0 last - private trusted"),
"{text}"
);
assert!(
text.contains(
"note session mm-b hits 1 last 2026-10-03T07:00:00.000Z private trusted"
),
"{text}"
);
}
@@ -0,0 +1,191 @@
//! Tests that `ToolSummary` is indexed as kind `summary` and found by `recall`. Do not edit.
#[path = "support/logs.rs"]
mod logs;
#[path = "support/tmp.rs"]
mod tmp;
use bxcore::{CallId, DataClass, LogRecord, RecallArgs, Timestamp};
use logs::{assistant, result, user, write_log};
use memoryd::files::Store;
use memoryd::index::Index;
use memoryd::recall::recall;
use tmp::TempDir;
fn now() -> Timestamp {
Timestamp::parse("2026-09-24T21:00:00.000Z").unwrap()
}
fn summary(minute: u32, content: &str, untrusted: bool) -> LogRecord {
LogRecord::ToolSummary {
time: logs::at(minute),
call: CallId(1),
tool_call_id: "c0".to_string(),
content: content.to_string(),
class: DataClass::Public,
untrusted,
}
}
struct Setup {
dir: TempDir,
store: Store,
index: Index,
}
fn setup(tag: &str) -> Setup {
let dir = TempDir::new(tag);
let starter = dir.write("etc/soul.md", "Soul.\n");
let store = Store::new(&dir.path().join("memory"));
store.init(&starter).unwrap();
let index = Index::open(&dir.path().join("index/memory.sqlite3"), DataClass::Private).unwrap();
Setup { dir, store, index }
}
fn ask(s: &Setup, query: &str) -> memoryd::recall::Recalled {
let args = RecallArgs {
query: query.to_string(),
k: 10,
};
recall(&s.index, &s.store, &args, "mm-now", now()).unwrap()
}
#[test]
fn a_tool_summary_is_found_as_kind_summary() {
let s = setup("summary-found");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch the launch page"),
assistant(
3,
None,
&[("http_fetch", r#"{"url":"https://example.org"}"#)],
),
result(4, "raw html body about tea", DataClass::Public, false),
summary(5, "the page is about tea and the launch window", false),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "tea");
assert!(
got.text
.contains("the page is about tea and the launch window"),
"{got:?}"
);
assert!(
got.text.contains("memory · session mm-a"),
"tagged as a session hit: {}",
got.text
);
assert!(!got.untrusted);
}
#[test]
fn the_raw_tool_result_is_still_not_indexed() {
let s = setup("summary-no-raw");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
result(4, "UNIQUE_RAW_BODY_XYZ", DataClass::Public, false),
summary(5, "a short account of the page", false),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "UNIQUE_RAW_BODY_XYZ");
assert!(
got.text.contains("(no matches"),
"raw tool result stays unindexed: {}",
got.text
);
}
#[test]
fn an_untrusted_summary_taints_the_recall() {
let s = setup("summary-taint");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
result(4, "page", DataClass::Public, true),
summary(5, "the fetched page mentions the launch window", true),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "launch");
assert!(got.untrusted, "{got:?}");
assert!(got.text.contains("memory-tainted"), "{}", got.text);
}
#[test]
fn a_trusted_summary_does_not_taint() {
let s = setup("summary-trusted");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
result(4, "page", DataClass::Public, false),
summary(5, "the fetched page mentions the launch window", false),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "launch");
assert!(!got.untrusted, "{got:?}");
assert!(got.text.contains("memory · session mm-a"), "{}", got.text);
assert!(!got.text.contains("memory-tainted"), "{}", got.text);
}
#[test]
fn a_secret_summary_is_skipped_when_the_index_is_private() {
let s = setup("summary-secret");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
LogRecord::ToolResult {
time: logs::at(4),
call: CallId(1),
tool_call_id: "c0".to_string(),
content: "secret page",
class: DataClass::Secret,
untrusted: false,
truncated: false,
},
LogRecord::ToolSummary {
time: logs::at(5),
call: CallId(1),
tool_call_id: "c0".to_string(),
content: "SECRET_SUMMARY_PHRASE",
class: DataClass::Secret,
untrusted: false,
},
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "SECRET_SUMMARY_PHRASE");
assert!(
got.text.contains("(no matches"),
"secret summary above max_class: {}",
got.text
);
}
@@ -0,0 +1,25 @@
## heartbeat-prompt-unreadable
**What you see.** `loopd` is serving, but heartbeat turns do not run. It logs this line once,
until the file reads:
```text
loopd: heartbeat.md: Permission denied (os error 13); see docs/runbook.md#heartbeat-prompt-unreadable
```
**Why.** `[heartbeat] prompt` is next to the config file (default `heartbeat.md`). The scheduler
skips the tick rather than send an empty or guessed user message. A missing file is the same:
restore the starter from `config/heartbeat.md` in the repository.
**Confirm.**
```sh
ls -l "$(dirname "$LOOPD_CONFIG")/heartbeat.md"
```
**Fix.** Restore mode `0644` (or whatever the owner uses for the other prompts) and content.
The next tick after a successful read logs nothing extra and runs the standing `heartbeat`
session.
**Check.** `sessions/heartbeat/0.jsonl` gains a new `User` whose text starts with `It is ` and an
RFC 3339 timestamp.
@@ -0,0 +1,24 @@
## jobs-invalid
**What you see.** `loopd` does not start. It prints a line naming `{home}/jobs/<name>.toml` and
this entry:
```text
loopd: jobs/mail.toml: unknown field `when`, expected `schedule` or `prompt`; see docs/runbook.md#jobs-invalid
```
**Why.** Job files are owner-written. `loopd` reads them and cannot write them. Unknown keys, a
name that is not `[a-z0-9-]+`, a name so long that `job-<name>-<yyyymmdd>t<hhmm>` is not a valid
session id (64 bytes, `[a-z0-9-]`), a schedule that is not five UTC fields, or a missing
`prompt`, all refuse start so a misspelt job cannot sit silently.
**Confirm.**
```sh
ls -l "$BOXMAKER_HOME/jobs/"
```
**Fix.** Rename or edit the named file so it matches the example in `config/jobs/tier.toml.example`.
Then start `loopd` again.
**Check.** `loopd serve` prints `serving on` and `{home}/jobs/runs.jsonl` exists (empty is fine).