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,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, ®istry).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, ®istry),
|
||||
"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, ®istry).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, ®istry),
|
||||
"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, ®istry).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, ®istry),
|
||||
"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, ®istry).unwrap(),
|
||||
cfg.slots.main,
|
||||
)
|
||||
.unwrap();
|
||||
let mut beat = Session::create(
|
||||
&home.dir,
|
||||
SessionId::new("heartbeat").unwrap(),
|
||||
Baseline::assemble(&cfg, ®istry).unwrap(),
|
||||
cfg.slots.background,
|
||||
)
|
||||
.unwrap();
|
||||
let rt = runtime(&cfg, &client, &port, ®istry);
|
||||
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, ®istry).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, ®istry).unwrap(),
|
||||
cfg.slots.main,
|
||||
)
|
||||
.unwrap();
|
||||
let rt = Runtime {
|
||||
cfg: &cfg,
|
||||
client: &client,
|
||||
port: &port,
|
||||
registry: ®istry,
|
||||
};
|
||||
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[..]);
|
||||
}
|
||||
Reference in New Issue
Block a user