1use std::sync::Arc;
10
11use hotl_engine::{
12 spawn_session, AskReply, EngineConfig, EngineEvent, Outcome, SessionDeps, SessionHandle,
13};
14use hotl_platform::SystemClock;
15use hotl_provider::{ProviderError, ScriptedProvider, StreamEvent};
16use hotl_store::{Masker, SessionLog};
17use hotl_tools::{rules::Rules, Registry};
18use hotl_types::{Entry, Item};
19
20pub use hotl_provider::ScriptedProvider as Scripted;
21
22pub struct Harness {
23 pub handle: SessionHandle,
24 pub provider: Arc<ScriptedProvider>,
25 pub seen: Vec<String>,
27 log_path: std::path::PathBuf,
28 _dir: tempfile::TempDir,
29 extra_dirs: Vec<tempfile::TempDir>,
32 pub ask_reply: AskReply,
34 pub steer_on_tool_start: Option<String>,
36 pub snapshots: Arc<std::sync::Mutex<Vec<String>>>,
38}
39
40struct RecordingSnapshotter(Arc<std::sync::Mutex<Vec<String>>>);
42
43impl hotl_engine::Snapshotter for RecordingSnapshotter {
44 fn snapshot(&self, label: String) -> futures_util::future::BoxFuture<'static, ()> {
45 self.0.lock().expect("snapshot log").push(label);
46 Box::pin(async {})
47 }
48}
49
50impl Harness {
51 pub fn dir(&self) -> &std::path::Path {
54 self._dir.path()
55 }
56
57 pub fn keep_dir(&mut self, dir: tempfile::TempDir) {
60 self.extra_dirs.push(dir);
61 }
62}
63
64impl Harness {
65 pub fn new(
66 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
67 config: EngineConfig,
68 ) -> Self {
69 Self::with_items(scripts, config, Vec::new())
70 }
71
72 pub fn with_items(
74 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
75 config: EngineConfig,
76 initial_items: Vec<Item>,
77 ) -> Self {
78 Self::build(scripts, config, initial_items, None)
79 }
80
81 pub fn with_hooks(
83 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
84 config: EngineConfig,
85 hooks: Arc<dyn hotl_engine::hooks::Hooks>,
86 ) -> Self {
87 Self::build_with(
88 scripts,
89 config,
90 Vec::new(),
91 Some(hooks),
92 Registry::builtin(),
93 )
94 }
95
96 pub fn with_registry(
99 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
100 config: EngineConfig,
101 registry: Registry,
102 ) -> Self {
103 Self::build_with(scripts, config, Vec::new(), None, registry)
104 }
105
106 fn build(
107 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
108 config: EngineConfig,
109 initial_items: Vec<Item>,
110 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
111 ) -> Self {
112 Self::build_with(scripts, config, initial_items, hooks, Registry::builtin())
113 }
114
115 pub fn with_rules(
118 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
119 config: EngineConfig,
120 rules: Rules,
121 ) -> Self {
122 Self::build_full(
123 scripts,
124 config,
125 Vec::new(),
126 None,
127 Registry::builtin(),
128 rules,
129 )
130 }
131
132 fn build_with(
133 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
134 config: EngineConfig,
135 initial_items: Vec<Item>,
136 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
137 registry: Registry,
138 ) -> Self {
139 Self::build_full(
140 scripts,
141 config,
142 initial_items,
143 hooks,
144 registry,
145 Rules::default(),
146 )
147 }
148
149 fn build_full(
150 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
151 config: EngineConfig,
152 initial_items: Vec<Item>,
153 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
154 registry: Registry,
155 rules: Rules,
156 ) -> Self {
157 let dir = tempfile::tempdir().expect("tempdir");
158 let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0)
159 .expect("session log");
160 let log_path = log.path().to_path_buf();
161 let provider = Arc::new(ScriptedProvider::new(scripts));
162 let snapshots = Arc::new(std::sync::Mutex::new(Vec::new()));
163 let deps = SessionDeps {
164 provider: provider.clone(),
165 registry: Arc::new(registry),
166 rules: Arc::new(rules),
167 sandbox_enforced: false,
168 clock: Arc::new(SystemClock),
169 log,
170 system: "test-system".into(),
171 cwd: dir.path().to_path_buf(),
172 snapshots: Some(Arc::new(RecordingSnapshotter(snapshots.clone()))),
173 hooks,
174 initial_items,
175 config,
176 };
177 let handle = spawn_session(deps);
178 Self {
179 handle,
180 provider,
181 seen: Vec::new(),
182 log_path,
183 _dir: dir,
184 extra_dirs: Vec::new(),
185 ask_reply: AskReply::Allow,
186 steer_on_tool_start: None,
187 snapshots,
188 }
189 }
190
191 pub async fn prompt_and_wait(&mut self, text: &str) -> Outcome {
193 self.handle.prompt(text.to_string()).await;
194 self.wait_for_outcome().await
195 }
196
197 pub async fn wait_for_outcome(&mut self) -> Outcome {
198 loop {
199 let event = tokio::time::timeout(
200 std::time::Duration::from_secs(10),
201 self.handle.events.recv(),
202 )
203 .await
204 .expect("event timeout")
205 .expect("event channel closed");
206 self.seen.push(format!("{event:?}"));
207 match event {
208 EngineEvent::Ask { reply, .. } => {
209 let _ = reply.send(self.ask_reply.clone());
210 }
211 EngineEvent::ToolStart { .. } => {
212 if let Some(steer) = self.steer_on_tool_start.take() {
213 self.handle.steer(steer).await;
214 }
215 }
216 EngineEvent::TurnDone { outcome, .. } => return outcome,
217 _ => {}
218 }
219 }
220 }
221
222 pub fn kinds(&self) -> Vec<String> {
224 self.entries()
225 .iter()
226 .map(|e| {
227 serde_json::to_value(&e.payload)
228 .ok()
229 .and_then(|v| v.get("kind").and_then(|k| k.as_str().map(String::from)))
230 .unwrap_or_else(|| "?".into())
231 })
232 .collect()
233 }
234
235 pub fn transcript(&self) -> String {
238 self.entries()
239 .iter()
240 .map(|e| {
241 let mut v = serde_json::to_value(e).expect("entry to value");
242 v["id"] = "ID".into();
243 v["parent_id"] = if e.parent_id.is_some() {
244 "PARENT".into()
245 } else {
246 serde_json::Value::Null
247 };
248 v["ts_ms"] = 0.into();
249 if let Some(h) = v.pointer_mut("/payload/header") {
250 h["session_id"] = "SESSION".into();
251 h["created_at_ms"] = 0.into();
252 }
253 v.to_string()
254 })
255 .collect::<Vec<_>>()
256 .join("\n")
257 }
258
259 pub fn entries(&self) -> Vec<Entry> {
260 std::fs::read_to_string(&self.log_path)
261 .expect("read log")
262 .lines()
263 .map(|l| serde_json::from_str(l).expect("parse entry"))
264 .collect()
265 }
266
267 pub fn items(&self) -> Vec<Item> {
269 self.entries()
270 .into_iter()
271 .filter_map(|e| match e.payload {
272 hotl_types::EntryPayload::Item { item } => Some(item),
273 _ => None,
274 })
275 .collect()
276 }
277
278 pub fn tool_calls(&self) -> Vec<(String, serde_json::Value)> {
281 self.items()
282 .iter()
283 .filter_map(|i| match i {
284 Item::Assistant { blocks } => Some(hotl_types::assistant_tool_uses(blocks)),
285 _ => None,
286 })
287 .flatten()
288 .map(|tu| (tu.name, tu.input))
289 .collect()
290 }
291
292 pub fn assert_trajectory(&self, expected: &[&str], mode: TrajectoryMatch) {
295 let actual: Vec<String> = self.tool_calls().into_iter().map(|(n, _)| n).collect();
296 let ok = match mode {
297 TrajectoryMatch::Exact => actual
298 .iter()
299 .map(String::as_str)
300 .eq(expected.iter().copied()),
301 TrajectoryMatch::Unordered => {
302 let mut a: Vec<&str> = actual.iter().map(String::as_str).collect();
303 let mut e = expected.to_vec();
304 a.sort_unstable();
305 e.sort_unstable();
306 a == e
307 }
308 TrajectoryMatch::Subset => is_subsequence(expected, &actual),
309 };
310 assert!(
311 ok,
312 "trajectory {mode:?} failed:\n expected: {expected:?}\n actual: {actual:?}"
313 );
314 }
315}
316
317#[derive(Debug, Clone, Copy)]
319pub enum TrajectoryMatch {
320 Exact,
322 Unordered,
324 Subset,
326}
327
328pub fn tool_batch(
330 calls: &[(&str, &str, serde_json::Value)],
331) -> Vec<Result<StreamEvent, ProviderError>> {
332 let blocks: Vec<serde_json::Value> = calls
333 .iter()
334 .map(|(id, name, input)| {
335 serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input})
336 })
337 .collect();
338 vec![
339 Ok(StreamEvent::Started),
340 Ok(StreamEvent::Completed {
341 stop: hotl_types::StopReason::ToolUse,
342 usage: hotl_types::TokenUsage {
343 input_tokens: 10,
344 output_tokens: 8,
345 ..Default::default()
346 },
347 blocks,
348 }),
349 ]
350}
351
352fn is_subsequence(needles: &[&str], haystack: &[String]) -> bool {
354 let mut it = haystack.iter();
355 needles.iter().all(|n| it.any(|h| h == n))
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use hotl_types::{StopReason, SyntheticReason, TokenUsage};
362 use serde_json::json;
363
364 fn cfg() -> EngineConfig {
365 EngineConfig {
366 max_turns: 6,
367 ..Default::default()
368 }
369 }
370
371 struct OverlapProbe {
377 safe: bool,
378 running: Arc<std::sync::atomic::AtomicUsize>,
379 peak: Arc<std::sync::atomic::AtomicUsize>,
380 }
381
382 impl hotl_tools::Tool for OverlapProbe {
383 fn name(&self) -> &'static str {
384 "probe"
385 }
386 fn description(&self) -> &str {
387 "waits for a partner call"
388 }
389 fn schema(&self) -> serde_json::Value {
390 json!({"type": "object"})
391 }
392 fn permission(&self, _: &serde_json::Value) -> hotl_tools::Permission {
393 hotl_tools::Permission::None
394 }
395 fn parallel_safe(&self) -> bool {
396 self.safe
397 }
398 fn run<'a>(
399 &'a self,
400 _input: serde_json::Value,
401 _cancel: tokio_util::sync::CancellationToken,
402 ) -> futures_util::future::BoxFuture<'a, hotl_tools::ToolOutcome> {
403 use std::sync::atomic::Ordering;
404 Box::pin(async move {
405 let now = self.running.fetch_add(1, Ordering::SeqCst) + 1;
406 self.peak.fetch_max(now, Ordering::SeqCst);
407 let mut saw_partner = self.peak.load(Ordering::SeqCst) >= 2;
408 for _ in 0..50 {
409 if saw_partner {
410 break;
411 }
412 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
413 saw_partner = self.peak.load(Ordering::SeqCst) >= 2;
414 }
415 self.running.fetch_sub(1, Ordering::SeqCst);
416 if saw_partner {
417 hotl_tools::ToolOutcome::ok("overlapped")
418 } else {
419 hotl_tools::ToolOutcome::err("did not overlap")
420 }
421 })
422 }
423 }
424
425 fn probe_registry(safe: bool) -> Registry {
426 let mut reg = Registry::builtin();
427 reg.register(Box::new(OverlapProbe {
428 safe,
429 running: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
430 peak: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
431 }));
432 reg
433 }
434
435 #[tokio::test]
436 async fn parallel_safe_calls_in_one_batch_overlap() {
437 let mut h = Harness::with_registry(
438 vec![
439 tool_batch(&[("t1", "probe", json!({})), ("t2", "probe", json!({}))]),
440 ScriptedProvider::text_reply("both ran"),
441 ],
442 cfg(),
443 probe_registry(true),
444 );
445 let outcome = h.prompt_and_wait("probe twice").await;
446 assert_eq!(
447 outcome,
448 Outcome::Done {
449 text: "both ran".into()
450 }
451 );
452 let items = h.items();
453 let Item::ToolResults { results } = &items[2] else {
454 panic!("expected results, got {items:#?}")
455 };
456 assert!(
458 results
459 .iter()
460 .all(|r| !r.is_error && r.content == "overlapped"),
461 "{results:?}"
462 );
463 let ids: Vec<_> = results.iter().map(|r| r.tool_use_id.as_str()).collect();
465 assert_eq!(ids, ["t1", "t2"]);
466 }
467
468 #[tokio::test]
469 async fn unsafe_calls_in_one_batch_stay_serial() {
470 let mut h = Harness::with_registry(
471 vec![
472 tool_batch(&[("t1", "probe", json!({})), ("t2", "probe", json!({}))]),
473 ScriptedProvider::text_reply("done"),
474 ],
475 cfg(),
476 probe_registry(false),
477 );
478 let outcome = h.prompt_and_wait("probe twice").await;
479 assert_eq!(
480 outcome,
481 Outcome::Done {
482 text: "done".into()
483 }
484 );
485 let items = h.items();
486 let Item::ToolResults { results } = &items[2] else {
487 panic!("expected results, got {items:#?}")
488 };
489 assert!(
491 results
492 .iter()
493 .all(|r| r.is_error && r.content.contains("did not overlap")),
494 "{results:?}"
495 );
496 }
497
498 fn auto_rules() -> hotl_tools::rules::Rules {
499 hotl_tools::rules::Rules::default().with_mode(hotl_tools::rules::PermissionMode::Auto)
500 }
501
502 #[tokio::test]
503 async fn auto_mode_runs_mutating_calls_without_asking() {
504 let mut h = Harness::with_rules(Vec::new(), cfg(), auto_rules());
509 let note = h.dir().join("notes.txt");
510 h.provider.push_script(ScriptedProvider::tool_call(
511 "t1",
512 "write",
513 json!({"path": note.to_str().unwrap(), "content": "x"}),
514 ));
515 h.provider
516 .push_script(ScriptedProvider::text_reply("ran silently"));
517 let outcome = h.prompt_and_wait("write the note").await;
518 assert_eq!(
519 outcome,
520 Outcome::Done {
521 text: "ran silently".into()
522 }
523 );
524 assert!(
526 !h.seen.iter().any(|e| e.starts_with("Ask(")),
527 "events: {:?}",
528 h.seen
529 );
530 assert!(
531 h.seen.iter().any(|e| e.contains("permissions.mode=auto")),
532 "events: {:?}",
533 h.seen
534 );
535 assert_eq!(
537 *h.snapshots.lock().unwrap(),
538 vec!["pre batch 1", "post batch 1"]
539 );
540 }
541
542 #[tokio::test]
543 async fn auto_mode_protected_write_still_asks() {
544 let mut h = Harness::with_rules(Vec::new(), cfg(), auto_rules());
545 let makefile = h.dir().join("Makefile");
548 h.provider.push_script(ScriptedProvider::tool_call(
549 "t1",
550 "write",
551 json!({"path": makefile.to_str().unwrap(), "content": "x"}),
552 ));
553 h.provider.push_script(ScriptedProvider::text_reply("done"));
554 h.prompt_and_wait("write the makefile").await;
555 assert!(
556 h.seen.iter().any(|e| e.starts_with("Ask(")),
557 "protected must ask: {:?}",
558 h.seen
559 );
560 }
561
562 #[tokio::test]
563 async fn auto_mode_doom_loop_stops_without_asking() {
564 let scripts: Vec<_> = (0..5)
565 .map(|_| ScriptedProvider::tool_call("t", "read", json!({"path": "/same"})))
566 .collect();
567 let mut h = Harness::with_rules(
568 scripts,
569 EngineConfig {
570 max_turns: 10,
571 ..Default::default()
572 },
573 auto_rules(),
574 );
575 let outcome = h.prompt_and_wait("go").await;
576 assert!(
577 matches!(outcome, Outcome::DoomLoop { .. }),
578 "got {outcome:?}"
579 );
580 assert!(
581 !h.seen.iter().any(|e| e.starts_with("Ask(")),
582 "no human to ask in auto: {:?}",
583 h.seen
584 );
585 }
586
587 #[tokio::test]
588 async fn golden_tool_roundtrip() {
589 let dir = tempfile::tempdir().unwrap();
590 let file = dir.path().join("hello.txt");
591 std::fs::write(&file, "hello from disk\n").unwrap();
592 let mut h = Harness::new(
593 vec![
594 ScriptedProvider::tool_call("t1", "read", json!({"path": file.to_str().unwrap()})),
595 ScriptedProvider::text_reply("The file says hello."),
596 ],
597 cfg(),
598 );
599 let outcome = h.prompt_and_wait("what does hello.txt say?").await;
600 assert_eq!(
601 outcome,
602 Outcome::Done {
603 text: "The file says hello.".into()
604 }
605 );
606 assert_eq!(
607 h.kinds(),
608 ["header", "item", "item", "usage", "item", "item", "usage"]
609 );
610 let t1 = h.transcript();
612 assert!(t1.contains("hello from disk"));
613 assert!(t1.contains("tool_use"));
614 }
615
616 #[tokio::test]
617 async fn denied_ask_feeds_error_back() {
618 let mut h = Harness::new(
619 vec![
620 ScriptedProvider::tool_call("t1", "bash", json!({"command": "rm -rf /"})),
621 ScriptedProvider::text_reply("Understood."),
622 ],
623 cfg(),
624 );
625 h.ask_reply = AskReply::Deny { message: None };
626 let outcome = h.prompt_and_wait("clean up").await;
627 assert!(matches!(outcome, Outcome::Done { .. }));
628 let items = h.items();
629 let Item::ToolResults { results } = &items[2] else {
630 panic!("expected results")
631 };
632 assert!(results[0].is_error && results[0].content.contains("declined"));
633 assert!(h.seen.iter().any(|e| e.starts_with("Ask(")));
634 }
635
636 #[tokio::test]
637 async fn steer_mid_turn_reaches_next_sample() {
638 let mut h = Harness::new(
641 vec![
642 ScriptedProvider::tool_call(
643 "t1",
644 "bash",
645 json!({"command": "sleep 0.2; echo done"}),
646 ),
647 ScriptedProvider::text_reply("Done, and noted your steer."),
648 ],
649 cfg(),
650 );
651 h.steer_on_tool_start = Some("also check the README".into());
652 let outcome = h.prompt_and_wait("run the thing").await;
653 assert!(matches!(outcome, Outcome::Done { .. }));
654
655 let steer_items: Vec<_> = h
657 .items()
658 .into_iter()
659 .filter(|i| {
660 matches!(
661 i,
662 Item::User {
663 synthetic: Some(SyntheticReason::Steer),
664 ..
665 }
666 )
667 })
668 .collect();
669 assert_eq!(steer_items.len(), 1);
670
671 let requests = h.provider.requests();
674 assert_eq!(requests.len(), 2);
675 let saw_in_first = requests[0].items.iter().any(is_steer);
676 let saw_in_second = requests[1].items.iter().any(is_steer);
677 assert!(
678 !saw_in_first,
679 "steer must not appear in the sample that was already running"
680 );
681 assert!(saw_in_second, "steer must be woven into the next sample");
682 }
683
684 fn is_steer(i: &Item) -> bool {
685 matches!(
686 i,
687 Item::User {
688 synthetic: Some(SyntheticReason::Steer),
689 ..
690 }
691 )
692 }
693
694 #[tokio::test]
695 async fn queued_prompt_promotes_after_turn() {
696 let mut h = Harness::new(
697 vec![
698 ScriptedProvider::tool_call("t1", "bash", json!({"command": "sleep 0.2"})),
699 ScriptedProvider::text_reply("first done"),
700 ScriptedProvider::text_reply("second done"),
701 ],
702 cfg(),
703 );
704 h.handle.prompt("first".into()).await;
705 h.handle.prompt("second".into()).await;
707 let first = h.wait_for_outcome().await;
708 assert_eq!(
709 first,
710 Outcome::Done {
711 text: "first done".into()
712 }
713 );
714 let second = h.wait_for_outcome().await;
715 assert_eq!(
716 second,
717 Outcome::Done {
718 text: "second done".into()
719 }
720 );
721 assert!(h.seen.iter().any(|e| e == "PromptQueued"));
722 }
723
724 #[tokio::test]
725 async fn doom_loop_stops_on_deny() {
726 let scripts: Vec<_> = (0..5)
727 .map(|_| ScriptedProvider::tool_call("t", "read", json!({"path": "/same"})))
728 .collect();
729 let mut h = Harness::new(
730 scripts,
731 EngineConfig {
732 max_turns: 10,
733 ..Default::default()
734 },
735 );
736 h.ask_reply = AskReply::Deny { message: None };
737 let outcome = h.prompt_and_wait("go").await;
738 assert!(
739 matches!(outcome, Outcome::DoomLoop { .. }),
740 "got {outcome:?}"
741 );
742 assert!(h.entries().iter().any(|e| matches!(
743 &e.payload,
744 hotl_types::EntryPayload::Cancelled { reason } if reason.contains("doom")
745 )));
746 }
747
748 #[tokio::test]
749 async fn fallback_model_on_availability_error() {
750 let mut h = Harness::new(
751 vec![
752 vec![Err(ProviderError::Transport("connection reset".into()))],
753 ScriptedProvider::text_reply("served by fallback"),
754 ],
755 EngineConfig {
756 fallback_models: vec!["backup-model".into()],
757 ..cfg()
758 },
759 );
760 let outcome = h.prompt_and_wait("hi").await;
761 assert_eq!(
762 outcome,
763 Outcome::Done {
764 text: "served by fallback".into()
765 }
766 );
767 assert!(h
768 .seen
769 .iter()
770 .any(|e| e.contains("FallbackModel(backup-model)")));
771 let reqs = h.provider.requests();
772 assert_eq!(reqs[1].model, "backup-model");
773 }
774
775 #[tokio::test]
776 async fn auth_error_does_not_fall_back() {
777 let mut h = Harness::new(
778 vec![vec![Err(ProviderError::Auth("bad key".into()))]],
779 EngineConfig {
780 fallback_models: vec!["backup".into()],
781 ..cfg()
782 },
783 );
784 let outcome = h.prompt_and_wait("hi").await;
785 assert!(matches!(outcome, Outcome::Error { .. }));
786 assert!(!h.seen.iter().any(|e| e.contains("FallbackModel")));
787 }
788
789 #[tokio::test]
790 async fn tool_failure_budget_stops_turn() {
791 let scripts: Vec<_> = (0..6)
793 .map(|i| {
794 ScriptedProvider::tool_call(
795 &format!("t{i}"),
796 "read",
797 json!({"path": format!("/nope{i}")}),
798 )
799 })
800 .collect();
801 let mut h = Harness::new(
802 scripts,
803 EngineConfig {
804 max_turns: 10,
805 tool_failure_budget: 3,
806 ..Default::default()
807 },
808 );
809 let outcome = h.prompt_and_wait("read them all").await;
810 assert_eq!(
811 outcome,
812 Outcome::ToolFailureBudget {
813 tool: "read".into()
814 }
815 );
816 let items = h.items();
818 let with_feedback = items.iter().any(|i| matches!(
819 i, Item::ToolResults { results } if results.iter().any(|r| r.content.contains("<retry attempts_left="))
820 ));
821 assert!(with_feedback);
822 }
823
824 #[tokio::test]
825 async fn max_turns_caps_runaway() {
826 let scripts: Vec<_> = (0..10)
827 .map(|i| {
828 ScriptedProvider::tool_call(
832 &format!("t{i}"),
833 "bash",
834 json!({"command": format!("echo {i}")}),
835 )
836 })
837 .collect();
838 let mut h = Harness::new(
839 scripts,
840 EngineConfig {
841 max_turns: 3,
842 ..Default::default()
843 },
844 );
845 let outcome = h.prompt_and_wait("loop").await;
846 assert_eq!(outcome, Outcome::TurnLimit);
847 }
848
849 #[tokio::test]
850 async fn interrupt_cancels_running_turn() {
851 let mut h = Harness::new(
852 vec![ScriptedProvider::tool_call(
853 "t1",
854 "bash",
855 json!({"command": "sleep 30"}),
856 )],
857 cfg(),
858 );
859 h.handle.prompt("run forever".into()).await;
860 loop {
862 let ev =
863 tokio::time::timeout(std::time::Duration::from_secs(5), h.handle.events.recv())
864 .await
865 .expect("timeout")
866 .expect("closed");
867 h.seen.push(format!("{ev:?}"));
868 match ev {
869 EngineEvent::Ask { reply, .. } => {
870 let _ = reply.send(AskReply::Allow);
871 }
872 EngineEvent::ToolStart { .. } => break,
873 _ => {}
874 }
875 }
876 h.handle.interrupt();
877 let outcome = h.wait_for_outcome().await;
878 assert_eq!(outcome, Outcome::Cancelled);
879 }
880
881 #[tokio::test]
882 async fn transcript_normalization_is_deterministic() {
883 let make = || async {
884 let mut h = Harness::new(vec![ScriptedProvider::text_reply("stable")], cfg());
885 h.prompt_and_wait("say something stable").await;
886 h.transcript()
887 };
888 let (a, b) = (make().await, make().await);
889 assert_eq!(
890 a, b,
891 "normalized transcripts must be byte-identical across runs"
892 );
893 }
894
895 fn tool_call_reporting(
899 id: &str,
900 name: &str,
901 input: serde_json::Value,
902 input_tokens: u64,
903 ) -> Vec<Result<StreamEvent, ProviderError>> {
904 let mut script = ScriptedProvider::tool_call(id, name, input);
905 if let Some(Ok(StreamEvent::Completed { usage, .. })) = script.last_mut() {
906 usage.input_tokens = input_tokens;
907 }
908 script
909 }
910
911 #[tokio::test]
912 async fn compaction_folds_history_and_continues() {
913 let cfg = EngineConfig {
918 context_window: 1000,
919 max_turns: 10,
920 ..Default::default()
921 };
922 let scripts = vec![
923 ScriptedProvider::tool_call(
924 "t1",
925 "bash",
926 json!({"command": format!("echo {}", "A".repeat(1100))}),
927 ),
928 tool_call_reporting(
929 "t2",
930 "bash",
931 json!({"command": format!("echo {}", "B".repeat(200))}),
932 750,
933 ),
934 ScriptedProvider::text_reply("GOAL: digest of earlier work"),
935 ScriptedProvider::text_reply("finished after compaction"),
936 ];
937 let mut h = Harness::new(scripts, cfg);
938 let outcome = h.prompt_and_wait("summarize both outputs").await;
939 assert_eq!(
940 outcome,
941 Outcome::Done {
942 text: "finished after compaction".into()
943 }
944 );
945 assert!(
946 h.seen.iter().any(|e| e == "Compacted(false)"),
947 "events: {:?}",
948 h.seen
949 );
950
951 assert!(h.kinds().iter().any(|k| k == "compaction"));
953 let requests = h.provider.requests();
954 assert_eq!(requests.len(), 4);
955 assert!(requests[2].system.contains("compress"));
957 let continuation = &requests[3];
959 assert!(matches!(
960 &continuation.items[0],
961 Item::User { synthetic: Some(SyntheticReason::CompactionSummary), text }
962 if text.contains("GOAL: digest of earlier work")
963 ));
964 let flat = format!("{:?}", continuation.items);
965 assert!(
966 !flat.contains(&"A".repeat(64)),
967 "folded history must not ride along"
968 );
969 assert!(flat.contains(&"B".repeat(64)), "the tail stays verbatim");
970 }
971
972 #[tokio::test]
973 async fn compaction_floor_survives_summarize_failure() {
974 let scripts = vec![
975 ScriptedProvider::tool_call("t1", "bash", json!({"command": "echo start"})),
976 tool_call_reporting("t2", "bash", json!({"command": "echo more"}), 900),
977 vec![Err(ProviderError::Transport("summarizer down".into()))],
979 vec![Err(ProviderError::Transport(
980 "summarizer still down".into(),
981 ))],
982 ScriptedProvider::text_reply("continued on the floor"),
983 ];
984 let mut h = Harness::new(
985 scripts,
986 EngineConfig {
987 context_window: 1000,
988 max_turns: 10,
989 ..Default::default()
990 },
991 );
992 let outcome = h.prompt_and_wait(&"x".repeat(1200)).await;
994 assert_eq!(
995 outcome,
996 Outcome::Done {
997 text: "continued on the floor".into()
998 }
999 );
1000 assert!(
1001 h.seen.iter().any(|e| e == "Compacted(true)"),
1002 "events: {:?}",
1003 h.seen
1004 );
1005 let degraded = h.entries().iter().any(|e| {
1006 matches!(
1007 &e.payload,
1008 hotl_types::EntryPayload::Compaction { degraded: true, .. }
1009 )
1010 });
1011 assert!(degraded, "the compaction entry records the floor");
1012 }
1013
1014 #[tokio::test]
1015 async fn moim_rides_the_request_but_never_the_log() {
1016 let mut h = Harness::new(vec![ScriptedProvider::text_reply("hi")], cfg());
1017 h.prompt_and_wait("hello").await;
1018 let requests = h.provider.requests();
1019 let tc = requests[0]
1020 .turn_context
1021 .as_deref()
1022 .expect("turn context attached");
1023 assert!(
1024 tc.contains("sample=\"1\"") && tc.contains("context_used="),
1025 "was: {tc}"
1026 );
1027 assert!(
1028 !h.transcript().contains("<turn-context"),
1029 "MOIM must never persist"
1030 );
1031 }
1032
1033 #[tokio::test]
1034 async fn subdir_agents_md_injected_on_first_touch() {
1035 let mut h = Harness::new(vec![], cfg());
1036 let sub = h.dir().join("web");
1037 std::fs::create_dir_all(&sub).unwrap();
1038 std::fs::write(sub.join("AGENTS.md"), "web subproject rules").unwrap();
1039 std::fs::write(sub.join("page.txt"), "content").unwrap();
1040 let path = sub.join("page.txt");
1041 h.provider.push_script(ScriptedProvider::tool_call(
1042 "t1",
1043 "read",
1044 json!({"path": path.to_str().unwrap()}),
1045 ));
1046 h.provider
1047 .push_script(ScriptedProvider::text_reply("read it"));
1048 let outcome = h.prompt_and_wait("read the page").await;
1049 assert!(matches!(outcome, Outcome::Done { .. }));
1050 drop(h.provider.requests());
1051 let hint_items: Vec<_> = h
1052 .items()
1053 .into_iter()
1054 .filter(|i| {
1055 matches!(
1056 i,
1057 Item::User {
1058 synthetic: Some(SyntheticReason::SubdirInstructions),
1059 ..
1060 }
1061 )
1062 })
1063 .collect();
1064 assert_eq!(hint_items.len(), 1, "items: {:#?}", h.items());
1065 let Item::User { text, .. } = &hint_items[0] else {
1066 unreachable!()
1067 };
1068 assert!(text.contains("web subproject rules") && text.contains("trust=\"untrusted\""));
1069 let requests = h.provider.requests();
1071 let flat = format!("{:?}", requests[1].items);
1072 assert!(flat.contains("web subproject rules"));
1073 }
1074
1075 #[tokio::test]
1076 async fn mutating_batches_are_bracketed_by_snapshots() {
1077 let mut h = Harness::new(
1078 vec![
1079 ScriptedProvider::tool_call("t1", "bash", json!({"command": "echo hi"})),
1080 ScriptedProvider::text_reply("done"),
1081 ],
1082 cfg(),
1083 );
1084 h.prompt_and_wait("run it").await;
1085 let labels = h.snapshots.lock().unwrap().clone();
1086 assert_eq!(labels, ["pre batch 1", "post batch 1"]);
1087
1088 let dir = tempfile::tempdir().unwrap();
1090 let file = dir.path().join("f.txt");
1091 std::fs::write(&file, "x").unwrap();
1092 let mut h = Harness::new(
1093 vec![
1094 ScriptedProvider::tool_call("t1", "read", json!({"path": file.to_str().unwrap()})),
1095 ScriptedProvider::text_reply("read"),
1096 ],
1097 cfg(),
1098 );
1099 h.prompt_and_wait("read it").await;
1100 assert!(h.snapshots.lock().unwrap().is_empty());
1101 }
1102
1103 #[tokio::test]
1104 async fn resume_continues_an_interrupted_turn() {
1105 let seeded = vec![Item::User {
1109 text: "half-finished request".into(),
1110 synthetic: None,
1111 }];
1112 let mut h = Harness::with_items(
1113 vec![ScriptedProvider::text_reply(
1114 "finished the interrupted turn",
1115 )],
1116 cfg(),
1117 seeded,
1118 );
1119 assert!(hotl_engine::needs_continuation(&h.items()) || h.items().is_empty());
1120 h.handle.continue_turn().await;
1121 let outcome = h.wait_for_outcome().await;
1122 assert_eq!(
1123 outcome,
1124 Outcome::Done {
1125 text: "finished the interrupted turn".into()
1126 }
1127 );
1128 let user_turns = h.provider.requests()[0]
1131 .items
1132 .iter()
1133 .filter(|i| {
1134 matches!(
1135 i,
1136 Item::User {
1137 synthetic: None,
1138 ..
1139 }
1140 )
1141 })
1142 .count();
1143 assert_eq!(user_turns, 1, "continue must not append a second user item");
1144 }
1145
1146 #[tokio::test]
1147 async fn continue_is_a_noop_on_a_complete_projection() {
1148 let done = vec![
1150 Item::User {
1151 text: "q".into(),
1152 synthetic: None,
1153 },
1154 Item::Assistant {
1155 blocks: vec![json!({"type":"text","text":"a"})],
1156 },
1157 ];
1158 assert!(!hotl_engine::needs_continuation(&done));
1159 }
1160
1161 #[tokio::test]
1162 async fn reset_mode_compaction_drops_the_verbatim_tail() {
1163 let cfg = EngineConfig {
1166 context_window: 1000,
1167 max_turns: 10,
1168 compaction_reset: true,
1169 ..Default::default()
1170 };
1171 let scripts = vec![
1172 ScriptedProvider::tool_call(
1173 "t1",
1174 "bash",
1175 json!({"command": format!("echo {}", "A".repeat(1100))}),
1176 ),
1177 tool_call_reporting(
1178 "t2",
1179 "bash",
1180 json!({"command": format!("echo {}", "B".repeat(200))}),
1181 750,
1182 ),
1183 ScriptedProvider::text_reply("GOAL: digest"),
1184 ScriptedProvider::text_reply("done after reset compaction"),
1185 ];
1186 let mut h = Harness::new(scripts, cfg);
1187 let outcome = h.prompt_and_wait("do the thing").await;
1188 assert_eq!(
1189 outcome,
1190 Outcome::Done {
1191 text: "done after reset compaction".into()
1192 }
1193 );
1194 assert!(h.seen.iter().any(|e| e.starts_with("Compacted")));
1195 let continuation = h.provider.last_request().unwrap();
1196 assert!(continuation.items.iter().any(|i| matches!(
1198 i,
1199 Item::User {
1200 synthetic: Some(SyntheticReason::CompactionSummary),
1201 ..
1202 }
1203 )));
1204 assert!(
1206 !continuation
1207 .items
1208 .iter()
1209 .any(|i| matches!(i, Item::ToolResults { .. } | Item::Assistant { .. })),
1210 "reset-mode continuation must not carry the verbatim tail: {:?}",
1211 continuation.items
1212 );
1213 }
1214
1215 #[tokio::test]
1216 async fn moim_context_pct_can_be_hidden() {
1217 let mut h = Harness::new(
1218 vec![ScriptedProvider::text_reply("hi")],
1219 EngineConfig {
1220 show_context_pct: false,
1221 ..cfg()
1222 },
1223 );
1224 h.prompt_and_wait("hello").await;
1225 let reqs = h.provider.requests();
1226 let tc = reqs[0].turn_context.as_deref().unwrap();
1227 assert!(!tc.contains("context_used"), "pct must be omitted: {tc}");
1228 assert!(tc.contains("sample="), "the rest of MOIM still rides");
1229 }
1230
1231 #[tokio::test]
1232 async fn pre_tool_hook_blocks_a_call() {
1233 use hotl_engine::hooks::{InProcessHooks, PreToolDecision};
1234 let hooks = Arc::new(InProcessHooks::new().on_pre_tool(|name, input| {
1235 if name == "bash" && input.get("command").and_then(|c| c.as_str()) == Some("danger") {
1236 PreToolDecision::Deny {
1237 message: "policy: no danger".into(),
1238 }
1239 } else {
1240 PreToolDecision::Continue
1241 }
1242 }));
1243 let mut h = Harness::with_hooks(
1244 vec![
1245 ScriptedProvider::tool_call("t1", "bash", json!({"command": "danger"})),
1246 ScriptedProvider::text_reply("understood, blocked"),
1247 ],
1248 cfg(),
1249 hooks,
1250 );
1251 let outcome = h.prompt_and_wait("do the dangerous thing").await;
1252 assert!(matches!(outcome, Outcome::Done { .. }));
1253 let blocked = h.items().into_iter().any(|i| matches!(
1255 i, Item::ToolResults { results } if results.iter().any(|r| r.is_error && r.content.contains("policy: no danger"))
1256 ));
1257 assert!(
1258 blocked,
1259 "the hook's denial reached the model as a tool result"
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn post_tool_hook_annotates_a_result() {
1265 use hotl_engine::hooks::InProcessHooks;
1266 let dir = tempfile::tempdir().unwrap();
1267 let file = dir.path().join("f.txt");
1268 std::fs::write(&file, "secret content").unwrap();
1269 let hooks = Arc::new(InProcessHooks::new().on_post_tool(|name, _result| {
1270 (name == "read").then(|| "[redacted by hook]".to_string())
1271 }));
1272 let mut h = Harness::with_hooks(
1273 vec![
1274 ScriptedProvider::tool_call("t1", "read", json!({"path": file.to_str().unwrap()})),
1275 ScriptedProvider::text_reply("read the redacted file"),
1276 ],
1277 cfg(),
1278 hooks,
1279 );
1280 h.prompt_and_wait("read it").await;
1281 let redacted = h.items().into_iter().any(|i| matches!(
1282 i, Item::ToolResults { results } if results.iter().any(|r| r.content.contains("[redacted by hook]"))
1283 ));
1284 assert!(
1285 redacted,
1286 "the post-tool hook replaced the result the model saw"
1287 );
1288 assert!(!h.transcript().contains("secret content"));
1290 }
1291
1292 #[tokio::test]
1293 async fn deny_with_message_reaches_the_model() {
1294 let mut h = Harness::new(
1297 vec![
1298 ScriptedProvider::tool_call("t1", "write", json!({"path": "a.md", "content": "x"})),
1299 ScriptedProvider::text_reply("understood, using notes.md"),
1300 ],
1301 cfg(),
1302 );
1303 h.ask_reply = AskReply::Deny {
1304 message: Some("wrong file — use notes.md".into()),
1305 };
1306 let outcome = h.prompt_and_wait("write it").await;
1307 assert!(matches!(outcome, Outcome::Done { .. }));
1308 let items = h.items();
1309 let Item::ToolResults { results } = &items[2] else {
1310 panic!("expected results")
1311 };
1312 assert!(results[0].is_error);
1313 assert!(
1314 results[0]
1315 .content
1316 .contains("declined this tool call: wrong file — use notes.md"),
1317 "the denial reason must reach the model: {}",
1318 results[0].content
1319 );
1320 }
1321
1322 async fn harness_read_then_write() -> Harness {
1323 let dir = tempfile::tempdir().unwrap();
1324 let f = dir.path().join("x.txt");
1325 std::fs::write(&f, "content").unwrap();
1326 let mut h = Harness::new(
1327 vec![
1328 ScriptedProvider::tool_call("t1", "read", json!({"path": f.to_str().unwrap()})),
1329 ScriptedProvider::tool_call(
1330 "t2",
1331 "write",
1332 json!({"path": f.to_str().unwrap(), "content": "new"}),
1333 ),
1334 ScriptedProvider::text_reply("did both"),
1335 ],
1336 EngineConfig {
1337 max_turns: 6,
1338 ..Default::default()
1339 },
1340 );
1341 h.keep_dir(dir);
1342 h.prompt_and_wait("read then write").await;
1343 h
1344 }
1345
1346 #[tokio::test]
1347 async fn trajectory_matches() {
1348 let h = harness_read_then_write().await;
1349 h.assert_trajectory(&["read", "write"], TrajectoryMatch::Exact);
1350 h.assert_trajectory(&["write", "read"], TrajectoryMatch::Unordered);
1351 h.assert_trajectory(&["write"], TrajectoryMatch::Subset);
1352 assert_eq!(h.tool_calls()[0].0, "read");
1353 assert_eq!(h.tool_calls()[1].1["content"], "new");
1354 }
1355
1356 #[tokio::test]
1357 #[should_panic(expected = "trajectory")]
1358 async fn trajectory_exact_rejects_wrong_order() {
1359 let h = harness_read_then_write().await;
1360 h.assert_trajectory(&["write", "read"], TrajectoryMatch::Exact);
1361 }
1362
1363 async fn read_big_with_threshold(threshold: u64) -> String {
1366 let dir = tempfile::tempdir().unwrap();
1367 let big = dir.path().join("big.txt");
1368 std::fs::write(&big, "B".repeat(60_000)).unwrap();
1369 let mut h = Harness::new(
1370 vec![
1371 ScriptedProvider::tool_call("t1", "read", json!({"path": big.to_str().unwrap()})),
1372 ScriptedProvider::text_reply("read it"),
1373 ],
1374 EngineConfig {
1375 evict_threshold_tokens: threshold,
1376 max_turns: 6,
1377 ..Default::default()
1378 },
1379 );
1380 h.prompt_and_wait("read the big file").await;
1381 drop(dir);
1382 let items = h.items();
1383 let Item::ToolResults { results } = &items[2] else {
1384 panic!("expected results")
1385 };
1386 if threshold != 0 {
1388 let has_blobs = std::fs::read_dir(h.dir())
1389 .unwrap()
1390 .filter_map(|e| e.ok())
1391 .any(|e| e.path().to_string_lossy().contains(".blobs"));
1392 assert!(
1393 has_blobs,
1394 "a .blobs dir should exist beside the log after eviction"
1395 );
1396 }
1397 results[0].content.clone()
1398 }
1399
1400 #[tokio::test]
1401 async fn oversized_tool_result_is_evicted_to_a_blob() {
1402 let content = read_big_with_threshold(5_000).await;
1403 assert!(content.contains("<evicted"), "result should be evicted");
1404 assert!(content.contains("Read it with the read tool"));
1405 assert!(
1406 content.len() < 5_000,
1407 "in-context result is a preview, not the full 60KB"
1408 );
1409 }
1410
1411 #[tokio::test]
1412 async fn eviction_disabled_at_threshold_zero() {
1413 let content = read_big_with_threshold(0).await;
1414 assert!(
1415 !content.contains("<evicted"),
1416 "threshold 0 disables eviction"
1417 );
1418 assert!(
1419 content.len() > 50_000,
1420 "the full result rides in-context when disabled"
1421 );
1422 }
1423
1424 #[allow(dead_code)]
1425 fn silence_unused(_: StopReason, _: TokenUsage) {}
1426}