146 lines
4.9 KiB
Rust
146 lines
4.9 KiB
Rust
//! 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::{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 body = sent[0].json();
|
|
let last = body["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);
|
|
}
|