use std::sync::Arc;
use std::time::Duration;
use futures_util::stream::BoxStream;
use hotl_engine::{
spawn_session, AskReply, EngineConfig, EngineEvent, Outcome, SessionDeps, SessionHandle,
};
use hotl_platform::SystemClock;
use hotl_provider::{Provider, ProviderError, SamplingRequest, ScriptedProvider, StreamEvent};
use hotl_store::{Masker, SessionLog};
use hotl_tools::{rules::Rules, Registry};
use serde_json::json;
struct Router {
main: Arc<ScriptedProvider>,
summarize: Arc<ScriptedProvider>,
}
impl Provider for Router {
fn stream(
&self,
req: SamplingRequest,
) -> BoxStream<'static, Result<StreamEvent, ProviderError>> {
if req.system.contains("compress") {
self.summarize.stream(req)
} else {
self.main.stream(req)
}
}
}
struct Session {
handle: SessionHandle,
dir: tempfile::TempDir,
}
fn session(provider: Arc<dyn Provider>, config: EngineConfig) -> Session {
let dir = tempfile::tempdir().expect("tempdir");
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0)
.expect("session log");
let handle = spawn_session(SessionDeps {
provider,
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "test-system".into(),
cwd: dir.path().to_path_buf(),
snapshots: None,
hooks: None,
initial_items: Vec::new(),
initial_todos: Vec::new(),
config,
});
Session { handle, dir }
}
async fn next_event(s: &mut Session) -> EngineEvent {
tokio::time::timeout(Duration::from_secs(30), s.handle.events.recv())
.await
.expect("event timeout")
.expect("event channel closed")
}
fn tool_call_reporting(
id: &str,
name: &str,
input: serde_json::Value,
input_tokens: u64,
) -> Vec<Result<StreamEvent, ProviderError>> {
let mut script = ScriptedProvider::tool_call(id, name, input);
if let Some(Ok(StreamEvent::Completed { usage, .. })) = script.last_mut() {
usage.input_tokens = input_tokens;
}
script
}
async fn run_to_done(s: &mut Session) -> (Outcome, usize) {
let mut folds = 0;
loop {
match next_event(s).await {
EngineEvent::Compacted { .. } => folds += 1,
EngineEvent::Ask { reply, .. } => {
let _ = reply.send(AskReply::Allow);
}
EngineEvent::TurnDone { outcome, .. } => return (outcome, folds),
_ => {}
}
}
}
#[tokio::test]
async fn max_turns_is_enforced_across_a_compaction() {
let main = Arc::new(ScriptedProvider::new(Vec::new()));
let summarize = Arc::new(ScriptedProvider::new(vec![
ScriptedProvider::text_reply("DIGEST"),
ScriptedProvider::text_reply("DIGEST 2"),
]));
let provider = Arc::new(Router {
main: Arc::clone(&main),
summarize,
});
let mut s = session(
provider,
EngineConfig {
context_window: 1000,
max_turns: 4,
..Default::default()
},
);
let file = s.dir.path().join("f.txt");
std::fs::write(&file, "small file body").expect("fixture");
let path = file.to_str().expect("utf8 path").to_string();
for i in 0..10 {
main.push_script(tool_call_reporting(
&format!("t{i}"),
"read",
json!({ "path": path }),
if i == 0 { 650 } else { 850 },
));
}
s.handle.prompt("run away".into()).await;
let (outcome, folds) = run_to_done(&mut s).await;
assert!(folds >= 1, "the fixture must actually compact");
assert_eq!(outcome, Outcome::TurnLimit);
assert!(
main.request_count() <= 4,
"max_turns must bound the whole logical turn, folds included; got {} samples",
main.request_count()
);
}
#[tokio::test]
async fn three_folds_with_progress_do_not_exhaust_the_streak() {
let main = Arc::new(ScriptedProvider::new(Vec::new()));
let summarize = Arc::new(ScriptedProvider::new(
(0..6)
.map(|i| ScriptedProvider::text_reply(&format!("DIGEST {i}")))
.collect(),
));
let provider = Arc::new(Router {
main: Arc::clone(&main),
summarize,
});
let mut s = session(
provider,
EngineConfig {
context_window: 1000,
max_turns: 20,
..Default::default()
},
);
let file = s.dir.path().join("f.txt");
std::fs::write(&file, "small file body").expect("fixture");
let path = file.to_str().expect("utf8 path").to_string();
let call = |i: usize, tokens: u64| {
tool_call_reporting(&format!("t{i}"), "read", json!({ "path": path }), tokens)
};
main.push_script(call(0, 650));
main.push_script(call(1, 850)); main.push_script(call(2, 850)); main.push_script(call(3, 850)); main.push_script(ScriptedProvider::text_reply("done after three folds"));
s.handle.prompt("a genuinely long turn".into()).await;
let (outcome, folds) = run_to_done(&mut s).await;
assert_eq!(folds, 3, "the fixture must fold three times");
assert_eq!(
outcome,
Outcome::Done {
text: "done after three folds".into()
}
);
}