use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use hotl_engine::hooks::{InProcessHooks, StopDecision};
use hotl_engine::{spawn_session, EngineConfig, EngineEvent, Outcome, SessionDeps};
use hotl_platform::SystemClock;
use hotl_provider::ScriptedProvider;
use hotl_store::{Masker, SessionLog};
use hotl_tools::{rules::Rules, Registry};
use hotl_types::{Entry, EntryPayload, Item, SyntheticReason, Todo, TodoStatus};
fn logged_payloads(path: &std::path::Path) -> Vec<EntryPayload> {
std::fs::read_to_string(path)
.expect("read log")
.lines()
.filter_map(|l| serde_json::from_str::<Entry>(l).ok())
.map(|e| e.payload)
.collect()
}
async fn run_to_done(handle: &mut hotl_engine::SessionHandle) -> Outcome {
loop {
let ev = tokio::time::timeout(Duration::from_secs(30), handle.events.recv())
.await
.expect("event timeout")
.expect("event channel closed");
if let EngineEvent::TurnDone { outcome, .. } = ev {
return outcome;
}
}
}
#[tokio::test]
async fn a_stop_hook_can_block_once_then_allow() {
let dir = tempfile::tempdir().expect("tempdir");
let config = EngineConfig::default();
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0).expect("log");
let log_path = log.path().to_path_buf();
let provider = Arc::new(ScriptedProvider::new(vec![
ScriptedProvider::text_reply("not done yet"),
ScriptedProvider::text_reply("done"),
]));
let fired = Arc::new(AtomicU32::new(0));
let fired_clone = fired.clone();
let hooks = InProcessHooks::new().on_stop(move |_outcome| {
if fired_clone.fetch_add(1, Ordering::SeqCst) == 0 {
StopDecision::Block {
reason: "not done: tests unrun".into(),
}
} else {
StopDecision::Allow
}
});
let mut handle = spawn_session(SessionDeps {
provider,
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "sys".into(),
cwd: dir.path().to_path_buf(),
snapshots: None,
hooks: Some(Arc::new(hooks)),
initial_items: Vec::new(),
initial_todos: Vec::new(),
config,
});
handle.prompt("go".into()).await;
let outcome = run_to_done(&mut handle).await;
assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
assert_eq!(
fired.load(Ordering::SeqCst),
2,
"hook consulted exactly twice"
);
let replayed = hotl_store::replay(&log_path).expect("replay");
let reminders: Vec<&Item> = replayed
.items
.iter()
.filter(|i| {
matches!(
i,
Item::User {
synthetic: Some(SyntheticReason::SystemReminder),
..
}
)
})
.collect();
assert_eq!(reminders.len(), 1, "exactly one continuation, one reminder");
let Item::User { text, .. } = reminders[0] else {
unreachable!()
};
assert!(text.contains("not done: tests unrun"), "{text}");
}
#[tokio::test]
async fn an_always_block_stop_hook_composed_with_the_todo_gate_never_exceeds_the_combined_cap() {
let dir = tempfile::tempdir().expect("tempdir");
let config = EngineConfig::default();
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0).expect("log");
let log_path = log.path().to_path_buf();
let provider = Arc::new(ScriptedProvider::new(vec![
ScriptedProvider::text_reply("still working"),
ScriptedProvider::text_reply("still working"),
ScriptedProvider::text_reply("still working"),
ScriptedProvider::text_reply("done"),
]));
let hooks = InProcessHooks::new().on_stop(|_outcome| StopDecision::Block {
reason: "owner policy: always keep going".into(),
});
let mut handle = spawn_session(SessionDeps {
provider: provider.clone(),
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "sys".into(),
cwd: dir.path().to_path_buf(),
snapshots: None,
hooks: Some(Arc::new(hooks)),
initial_items: Vec::new(),
initial_todos: Vec::new(),
config,
});
handle
.set_todos(vec![Todo {
content: "wire the gate".into(),
status: TodoStatus::InProgress,
active_form: None,
}])
.await;
handle.prompt("go".into()).await;
let outcome = run_to_done(&mut handle).await;
assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
assert_eq!(provider.request_count(), 4);
let entries = logged_payloads(&log_path);
let reminders = entries
.iter()
.filter(|p| {
matches!(
p,
EntryPayload::Item {
item: Item::User {
synthetic: Some(SyntheticReason::SystemReminder),
..
}
}
)
})
.count();
assert_eq!(
reminders, 3,
"the combined TodoGate + Stop budget must cap at TURN_EXTENSION_MAX (3) total, \
never the sum of each gate's own bound"
);
}
#[tokio::test]
async fn no_hooks_means_stop_never_fires_and_todo_gate_is_unaffected() {
let dir = tempfile::tempdir().expect("tempdir");
let config = EngineConfig::default();
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0).expect("log");
let provider = Arc::new(ScriptedProvider::new(vec![ScriptedProvider::text_reply(
"done",
)]));
let mut handle = spawn_session(SessionDeps {
provider,
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "sys".into(),
cwd: dir.path().to_path_buf(),
snapshots: None,
hooks: None,
initial_items: Vec::new(),
initial_todos: Vec::new(),
config,
});
handle.prompt("go".into()).await;
let outcome = run_to_done(&mut handle).await;
assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
}