1use std::sync::Arc;
10
11use hotl_engine::{
12 spawn_session, AskReply, EngineConfig, EngineEvent, LedgerSummary, Outcome, SessionDeps,
13 SessionHandle,
14};
15use hotl_platform::SystemClock;
16use hotl_provider::{Provider, ProviderError, SamplingRequest, ScriptedProvider, StreamEvent};
17use hotl_store::{Masker, SessionLog};
18use hotl_tools::{rules::Rules, Registry};
19use hotl_types::{Entry, Item};
20
21pub use hotl_provider::ScriptedProvider as Scripted;
22
23pub struct Harness {
24 pub handle: SessionHandle,
25 pub provider: Arc<ScriptedProvider>,
26 pub seen: Vec<String>,
28 log_path: std::path::PathBuf,
29 _dir: tempfile::TempDir,
30 extra_dirs: Vec<tempfile::TempDir>,
33 pub ask_reply: AskReply,
35 pub steer_on_tool_start: Option<String>,
37 pub steer_on_text_delta: Option<String>,
42 pub snapshots: Arc<std::sync::Mutex<Vec<String>>>,
44 pub ledger_reports: Vec<LedgerSummary>,
46 fsyncs: Arc<std::sync::atomic::AtomicU64>,
50}
51
52struct PausedCompletion {
59 inner: Arc<ScriptedProvider>,
60 pause: std::time::Duration,
61}
62
63impl Provider for PausedCompletion {
64 fn stream(
65 &self,
66 req: SamplingRequest,
67 ) -> futures_util::stream::BoxStream<'static, Result<StreamEvent, ProviderError>> {
68 use futures_util::StreamExt;
69 let pause = self.pause;
70 Box::pin(self.inner.stream(req).then(move |event| async move {
71 if matches!(event, Ok(StreamEvent::Completed { .. })) {
72 tokio::time::sleep(pause).await;
73 }
74 event
75 }))
76 }
77}
78
79type ProviderWrap = Box<dyn FnOnce(Arc<ScriptedProvider>) -> Arc<dyn Provider>>;
82
83struct RecordingSnapshotter(Arc<std::sync::Mutex<Vec<String>>>);
85
86impl hotl_engine::Snapshotter for RecordingSnapshotter {
87 fn snapshot(&self, label: String) -> futures_util::future::BoxFuture<'static, ()> {
88 self.0.lock().expect("snapshot log").push(label);
89 Box::pin(async {})
90 }
91}
92
93impl Harness {
94 pub fn dir(&self) -> &std::path::Path {
97 self._dir.path()
98 }
99
100 pub fn keep_dir(&mut self, dir: tempfile::TempDir) {
103 self.extra_dirs.push(dir);
104 }
105
106 pub fn log_path(&self) -> &std::path::Path {
109 &self.log_path
110 }
111}
112
113impl Harness {
114 pub fn new(
115 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
116 config: EngineConfig,
117 ) -> Self {
118 Self::with_items(scripts, config, Vec::new())
119 }
120
121 pub fn with_items(
123 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
124 config: EngineConfig,
125 initial_items: Vec<Item>,
126 ) -> Self {
127 Self::build(scripts, config, initial_items, None)
128 }
129
130 pub fn with_hooks(
132 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
133 config: EngineConfig,
134 hooks: Arc<dyn hotl_engine::hooks::Hooks>,
135 ) -> Self {
136 Self::build_with(
137 scripts,
138 config,
139 Vec::new(),
140 Some(hooks),
141 Registry::builtin(),
142 )
143 }
144
145 pub fn with_registry(
148 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
149 config: EngineConfig,
150 registry: Registry,
151 ) -> Self {
152 Self::build_with(scripts, config, Vec::new(), None, registry)
153 }
154
155 pub fn with_items_and_registry(
160 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
161 config: EngineConfig,
162 initial_items: Vec<Item>,
163 registry: Registry,
164 ) -> Self {
165 Self::build_with(scripts, config, initial_items, None, registry)
166 }
167
168 pub fn with_registry_sync_noop(
173 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
174 config: EngineConfig,
175 registry: Registry,
176 ) -> Self {
177 Self::build_full(
178 scripts,
179 config,
180 Vec::new(),
181 None,
182 registry,
183 Rules::default(),
184 true,
185 )
186 }
187
188 fn build(
189 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
190 config: EngineConfig,
191 initial_items: Vec<Item>,
192 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
193 ) -> Self {
194 Self::build_with(scripts, config, initial_items, hooks, Registry::builtin())
195 }
196
197 pub fn with_paused_completion(
200 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
201 config: EngineConfig,
202 pause: std::time::Duration,
203 ) -> Self {
204 Self::build_wrapped(
205 scripts,
206 config,
207 Vec::new(),
208 None,
209 Registry::builtin(),
210 Rules::default(),
211 false,
212 Some(Box::new(move |inner| {
213 Arc::new(PausedCompletion { inner, pause })
214 })),
215 )
216 }
217
218 pub fn with_rules(
221 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
222 config: EngineConfig,
223 rules: Rules,
224 ) -> Self {
225 Self::build_full(
226 scripts,
227 config,
228 Vec::new(),
229 None,
230 Registry::builtin(),
231 rules,
232 false,
233 )
234 }
235
236 fn build_with(
237 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
238 config: EngineConfig,
239 initial_items: Vec<Item>,
240 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
241 registry: Registry,
242 ) -> Self {
243 Self::build_full(
244 scripts,
245 config,
246 initial_items,
247 hooks,
248 registry,
249 Rules::default(),
250 false,
251 )
252 }
253
254 fn build_full(
255 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
256 config: EngineConfig,
257 initial_items: Vec<Item>,
258 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
259 registry: Registry,
260 rules: Rules,
261 sync_noop: bool,
262 ) -> Self {
263 Self::build_wrapped(
264 scripts,
265 config,
266 initial_items,
267 hooks,
268 registry,
269 rules,
270 sync_noop,
271 None,
272 )
273 }
274
275 #[allow(clippy::too_many_arguments)]
279 fn build_wrapped(
280 scripts: Vec<Vec<Result<StreamEvent, ProviderError>>>,
281 config: EngineConfig,
282 initial_items: Vec<Item>,
283 hooks: Option<Arc<dyn hotl_engine::hooks::Hooks>>,
284 registry: Registry,
285 rules: Rules,
286 sync_noop: bool,
287 wrap: Option<ProviderWrap>,
288 ) -> Self {
289 let dir = tempfile::tempdir().expect("tempdir");
290 let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0)
291 .expect("session log");
292 if sync_noop {
293 log.set_sync_noop(true);
294 }
295 let fsyncs = log.fsync_counter();
296 let log_path = log.path().to_path_buf();
297 let provider = Arc::new(ScriptedProvider::new(scripts));
298 let snapshots = Arc::new(std::sync::Mutex::new(Vec::new()));
299 let engine_provider: Arc<dyn Provider> = match wrap {
300 Some(wrap) => wrap(Arc::clone(&provider)),
301 None => provider.clone(),
302 };
303 let deps = SessionDeps {
304 provider: engine_provider,
305 registry: Arc::new(registry),
306 rules: Arc::new(rules),
307 sandbox_enforced: false,
308 clock: Arc::new(SystemClock),
309 log,
310 system: "test-system".into(),
311 cwd: dir.path().to_path_buf(),
312 snapshots: Some(Arc::new(RecordingSnapshotter(snapshots.clone()))),
313 hooks,
314 initial_items,
315 initial_todos: Vec::new(),
316 config,
317 };
318 let handle = spawn_session(deps);
319 Self {
320 handle,
321 provider,
322 seen: Vec::new(),
323 log_path,
324 _dir: dir,
325 extra_dirs: Vec::new(),
326 ask_reply: AskReply::Allow,
327 steer_on_tool_start: None,
328 steer_on_text_delta: None,
329 snapshots,
330 ledger_reports: Vec::new(),
331 fsyncs,
332 }
333 }
334
335 pub fn fsync_count(&self) -> u64 {
337 self.fsyncs.load(std::sync::atomic::Ordering::SeqCst)
338 }
339
340 pub async fn prompt_and_wait(&mut self, text: &str) -> Outcome {
342 self.handle.prompt(text.to_string()).await;
343 self.wait_for_outcome().await
344 }
345
346 pub async fn wait_for_outcome(&mut self) -> Outcome {
347 loop {
348 let event = tokio::time::timeout(
349 std::time::Duration::from_secs(10),
350 self.handle.events.recv(),
351 )
352 .await
353 .expect("event timeout")
354 .expect("event channel closed");
355 self.seen.push(format!("{event:?}"));
356 match event {
357 EngineEvent::Ask { reply, .. } => {
358 let _ = reply.send(self.ask_reply.clone());
359 }
360 EngineEvent::ToolStart { .. } => {
361 if let Some(steer) = self.steer_on_tool_start.take() {
362 self.handle.steer(steer).await;
363 }
364 }
365 EngineEvent::TextDelta(_) | EngineEvent::ThinkingDelta(_) => {
366 if let Some(steer) = self.steer_on_text_delta.take() {
367 self.handle.steer(steer).await;
368 }
369 }
370 EngineEvent::TurnDone { outcome, .. } => return outcome,
371 EngineEvent::LedgerReport(summary) => self.ledger_reports.push(summary),
372 _ => {}
373 }
374 }
375 }
376
377 pub fn kinds(&self) -> Vec<String> {
379 self.entries()
380 .iter()
381 .map(|e| {
382 serde_json::to_value(&e.payload)
383 .ok()
384 .and_then(|v| v.get("kind").and_then(|k| k.as_str().map(String::from)))
385 .unwrap_or_else(|| "?".into())
386 })
387 .collect()
388 }
389
390 pub fn transcript(&self) -> String {
393 self.entries()
394 .iter()
395 .map(|e| {
396 let mut v = serde_json::to_value(e).expect("entry to value");
397 v["id"] = "ID".into();
398 v["parent_id"] = if e.parent_id.is_some() {
399 "PARENT".into()
400 } else {
401 serde_json::Value::Null
402 };
403 v["ts_ms"] = 0.into();
404 if let Some(h) = v.pointer_mut("/payload/header") {
405 h["session_id"] = "SESSION".into();
406 h["created_at_ms"] = 0.into();
407 }
408 v.to_string()
409 })
410 .collect::<Vec<_>>()
411 .join("\n")
412 }
413
414 pub fn entries(&self) -> Vec<Entry> {
415 std::fs::read_to_string(&self.log_path)
416 .expect("read log")
417 .lines()
418 .map(|l| serde_json::from_str(l).expect("parse entry"))
419 .collect()
420 }
421
422 pub fn items(&self) -> Vec<Item> {
424 self.entries()
425 .into_iter()
426 .filter_map(|e| match e.payload {
427 hotl_types::EntryPayload::Item { item } => Some(item),
428 _ => None,
429 })
430 .collect()
431 }
432
433 pub fn tool_calls(&self) -> Vec<(String, serde_json::Value)> {
436 self.items()
437 .iter()
438 .filter_map(|i| match i {
439 Item::Assistant { blocks } => Some(hotl_types::assistant_tool_uses(blocks)),
440 _ => None,
441 })
442 .flatten()
443 .map(|tu| (tu.name, tu.input))
444 .collect()
445 }
446
447 pub fn assert_trajectory(&self, expected: &[&str], mode: TrajectoryMatch) {
450 let actual: Vec<String> = self.tool_calls().into_iter().map(|(n, _)| n).collect();
451 let ok = match mode {
452 TrajectoryMatch::Exact => actual
453 .iter()
454 .map(String::as_str)
455 .eq(expected.iter().copied()),
456 TrajectoryMatch::Unordered => {
457 let mut a: Vec<&str> = actual.iter().map(String::as_str).collect();
458 let mut e = expected.to_vec();
459 a.sort_unstable();
460 e.sort_unstable();
461 a == e
462 }
463 TrajectoryMatch::Subset => is_subsequence(expected, &actual),
464 };
465 assert!(
466 ok,
467 "trajectory {mode:?} failed:\n expected: {expected:?}\n actual: {actual:?}"
468 );
469 }
470}
471
472#[derive(Debug, Clone, Copy)]
474pub enum TrajectoryMatch {
475 Exact,
477 Unordered,
479 Subset,
481}
482
483pub fn tool_batch(
485 calls: &[(&str, &str, serde_json::Value)],
486) -> Vec<Result<StreamEvent, ProviderError>> {
487 let blocks: Vec<serde_json::Value> = calls
488 .iter()
489 .map(|(id, name, input)| {
490 serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input})
491 })
492 .collect();
493 vec![
494 Ok(StreamEvent::Started),
495 Ok(StreamEvent::Completed {
496 stop: hotl_types::StopReason::ToolUse,
497 usage: hotl_types::TokenUsage {
498 input_tokens: 10,
499 output_tokens: 8,
500 ..Default::default()
501 },
502 blocks,
503 }),
504 ]
505}
506
507fn is_subsequence(needles: &[&str], haystack: &[String]) -> bool {
509 let mut it = haystack.iter();
510 needles.iter().all(|n| it.any(|h| h == n))
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use hotl_types::{StopReason, SyntheticReason, TokenUsage};
517 use serde_json::json;
518
519 fn cfg() -> EngineConfig {
520 EngineConfig {
521 max_turns: 6,
522 ..Default::default()
523 }
524 }
525
526 struct OverlapProbe {
532 safe: bool,
533 running: Arc<std::sync::atomic::AtomicUsize>,
534 peak: Arc<std::sync::atomic::AtomicUsize>,
535 }
536
537 impl hotl_tools::Tool for OverlapProbe {
538 fn name(&self) -> &'static str {
539 "probe"
540 }
541 fn description(&self) -> &str {
542 "waits for a partner call"
543 }
544 fn schema(&self) -> serde_json::Value {
545 json!({"type": "object"})
546 }
547 fn permission(&self, _: &serde_json::Value) -> hotl_tools::Permission {
548 hotl_tools::Permission::None
549 }
550 fn parallel_safe(&self) -> bool {
551 self.safe
552 }
553 fn run<'a>(
554 &'a self,
555 _input: serde_json::Value,
556 _cancel: tokio_util::sync::CancellationToken,
557 ) -> futures_util::future::BoxFuture<'a, hotl_tools::ToolOutcome> {
558 use std::sync::atomic::Ordering;
559 Box::pin(async move {
560 let now = self.running.fetch_add(1, Ordering::SeqCst) + 1;
561 self.peak.fetch_max(now, Ordering::SeqCst);
562 let mut saw_partner = self.peak.load(Ordering::SeqCst) >= 2;
563 for _ in 0..50 {
564 if saw_partner {
565 break;
566 }
567 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
568 saw_partner = self.peak.load(Ordering::SeqCst) >= 2;
569 }
570 self.running.fetch_sub(1, Ordering::SeqCst);
571 if saw_partner {
572 hotl_tools::ToolOutcome::ok("overlapped")
573 } else {
574 hotl_tools::ToolOutcome::err("did not overlap")
575 }
576 })
577 }
578 }
579
580 fn probe_registry(safe: bool) -> Registry {
581 let mut reg = Registry::builtin();
582 reg.register(Box::new(OverlapProbe {
583 safe,
584 running: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
585 peak: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
586 }));
587 reg
588 }
589
590 #[tokio::test]
591 async fn parallel_safe_calls_in_one_batch_overlap() {
592 let mut h = Harness::with_registry(
593 vec![
594 tool_batch(&[("t1", "probe", json!({})), ("t2", "probe", json!({}))]),
595 ScriptedProvider::text_reply("both ran"),
596 ],
597 cfg(),
598 probe_registry(true),
599 );
600 let outcome = h.prompt_and_wait("probe twice").await;
601 assert_eq!(
602 outcome,
603 Outcome::Done {
604 text: "both ran".into()
605 }
606 );
607 let items = h.items();
608 let Item::ToolResults { results } = &items[2] else {
609 panic!("expected results, got {items:#?}")
610 };
611 assert!(
613 results
614 .iter()
615 .all(|r| !r.is_error && r.content == "overlapped"),
616 "{results:?}"
617 );
618 let ids: Vec<_> = results.iter().map(|r| r.tool_use_id.as_str()).collect();
620 assert_eq!(ids, ["t1", "t2"]);
621 }
622
623 #[tokio::test]
624 async fn unsafe_calls_in_one_batch_stay_serial() {
625 let mut h = Harness::with_registry(
626 vec![
627 tool_batch(&[("t1", "probe", json!({})), ("t2", "probe", json!({}))]),
628 ScriptedProvider::text_reply("done"),
629 ],
630 cfg(),
631 probe_registry(false),
632 );
633 let outcome = h.prompt_and_wait("probe twice").await;
634 assert_eq!(
635 outcome,
636 Outcome::Done {
637 text: "done".into()
638 }
639 );
640 let items = h.items();
641 let Item::ToolResults { results } = &items[2] else {
642 panic!("expected results, got {items:#?}")
643 };
644 assert!(
646 results
647 .iter()
648 .all(|r| r.is_error && r.content.contains("did not overlap")),
649 "{results:?}"
650 );
651 }
652
653 fn auto_rules() -> hotl_tools::rules::Rules {
654 hotl_tools::rules::Rules::default().with_mode(hotl_tools::rules::PermissionMode::Auto)
655 }
656
657 #[tokio::test]
658 async fn auto_mode_runs_mutating_calls_without_asking() {
659 let mut h = Harness::with_rules(Vec::new(), cfg(), auto_rules());
669 let scratch = tempfile::TempDir::new_in(".").expect("scratch dir");
670 let note = format!(
671 "{}/notes.txt",
672 scratch.path().file_name().unwrap().to_str().unwrap()
673 );
674 h.provider.push_script(ScriptedProvider::tool_call(
675 "t1",
676 "write",
677 json!({"path": note, "content": "x"}),
678 ));
679 h.provider
680 .push_script(ScriptedProvider::text_reply("ran silently"));
681 let outcome = h.prompt_and_wait("write the note").await;
682 assert_eq!(
683 outcome,
684 Outcome::Done {
685 text: "ran silently".into()
686 }
687 );
688 assert!(
690 !h.seen.iter().any(|e| e.starts_with("Ask(")),
691 "events: {:?}",
692 h.seen
693 );
694 assert!(scratch.path().join("notes.txt").exists(), "write ran");
695 assert!(
696 h.seen.iter().any(|e| e.contains("permissions.mode=auto")),
697 "events: {:?}",
698 h.seen
699 );
700 assert_eq!(
702 *h.snapshots.lock().unwrap(),
703 vec!["pre batch 1", "post batch 1"]
704 );
705 }
706
707 #[tokio::test]
708 async fn auto_mode_protected_write_still_asks() {
709 let mut h = Harness::with_rules(Vec::new(), cfg(), auto_rules());
710 let makefile = h.dir().join("Makefile");
713 h.provider.push_script(ScriptedProvider::tool_call(
714 "t1",
715 "write",
716 json!({"path": makefile.to_str().unwrap(), "content": "x"}),
717 ));
718 h.provider.push_script(ScriptedProvider::text_reply("done"));
719 h.prompt_and_wait("write the makefile").await;
720 assert!(
721 h.seen.iter().any(|e| e.starts_with("Ask(")),
722 "protected must ask: {:?}",
723 h.seen
724 );
725 }
726
727 #[tokio::test]
728 async fn auto_mode_doom_loop_stops_without_asking() {
729 let scripts: Vec<_> = (0..5)
734 .map(|_| ScriptedProvider::tool_call("t", "read", json!({"path": "same"})))
735 .collect();
736 let mut h = Harness::with_rules(
737 scripts,
738 EngineConfig {
739 max_turns: 10,
740 ..Default::default()
741 },
742 auto_rules(),
743 );
744 let outcome = h.prompt_and_wait("go").await;
745 assert!(
746 matches!(outcome, Outcome::DoomLoop { .. }),
747 "got {outcome:?}"
748 );
749 assert!(
750 !h.seen.iter().any(|e| e.starts_with("Ask(")),
751 "no human to ask in auto: {:?}",
752 h.seen
753 );
754 }
755
756 #[tokio::test]
757 async fn golden_tool_roundtrip() {
758 let dir = tempfile::tempdir().unwrap();
759 let file = dir.path().join("hello.txt");
760 std::fs::write(&file, "hello from disk\n").unwrap();
761 let mut h = Harness::new(
762 vec![
763 ScriptedProvider::tool_call("t1", "read", json!({"path": file.to_str().unwrap()})),
764 ScriptedProvider::text_reply("The file says hello."),
765 ],
766 cfg(),
767 );
768 let outcome = h.prompt_and_wait("what does hello.txt say?").await;
769 assert_eq!(
770 outcome,
771 Outcome::Done {
772 text: "The file says hello.".into()
773 }
774 );
775 assert_eq!(
780 h.kinds(),
781 [
782 "header",
783 "item",
784 "item",
785 "usage",
786 "pending_ask",
787 "ask_resolved",
788 "item",
789 "item",
790 "usage"
791 ]
792 );
793 let t1 = h.transcript();
795 assert!(t1.contains("hello from disk"));
796 assert!(t1.contains("tool_use"));
797 }
798
799 #[tokio::test]
800 async fn denied_ask_feeds_error_back() {
801 let mut h = Harness::new(
802 vec![
803 ScriptedProvider::tool_call("t1", "bash", json!({"command": "rm -rf /"})),
804 ScriptedProvider::text_reply("Understood."),
805 ],
806 cfg(),
807 );
808 h.ask_reply = AskReply::Deny { message: None };
809 let outcome = h.prompt_and_wait("clean up").await;
810 assert!(matches!(outcome, Outcome::Done { .. }));
811 let items = h.items();
812 let Item::ToolResults { results } = &items[2] else {
813 panic!("expected results")
814 };
815 assert!(results[0].is_error && results[0].content.contains("declined"));
816 assert!(h.seen.iter().any(|e| e.starts_with("Ask(")));
817 }
818
819 #[tokio::test]
820 async fn steer_mid_turn_reaches_next_sample() {
821 let mut h = Harness::new(
824 vec![
825 ScriptedProvider::tool_call(
826 "t1",
827 "bash",
828 json!({"command": "sleep 0.2; echo done"}),
829 ),
830 ScriptedProvider::text_reply("Done, and noted your steer."),
831 ],
832 cfg(),
833 );
834 h.steer_on_tool_start = Some("also check the README".into());
835 let outcome = h.prompt_and_wait("run the thing").await;
836 assert!(matches!(outcome, Outcome::Done { .. }));
837
838 let steer_items: Vec<_> = h
840 .items()
841 .into_iter()
842 .filter(|i| {
843 matches!(
844 i,
845 Item::User {
846 synthetic: Some(SyntheticReason::Steer),
847 ..
848 }
849 )
850 })
851 .collect();
852 assert_eq!(steer_items.len(), 1);
853
854 let requests = h.provider.requests();
864 assert_eq!(
865 requests.len(),
866 3,
867 "sample 1, the mispredicted optimistic dispatch, and the rebuild"
868 );
869 assert!(
870 !requests[0].items.iter().any(is_steer),
871 "steer must not appear in the sample that was already running"
872 );
873 assert!(
874 !requests[1].items.iter().any(is_steer),
875 "the speculative request was built before the steer landed"
876 );
877 assert!(
878 requests[2].items.iter().any(is_steer),
879 "steer must be woven into the sample that actually ran"
880 );
881 }
882
883 fn is_steer(i: &Item) -> bool {
884 matches!(
885 i,
886 Item::User {
887 synthetic: Some(SyntheticReason::Steer),
888 ..
889 }
890 )
891 }
892
893 #[tokio::test]
894 async fn queued_prompt_promotes_after_turn() {
895 let mut h = Harness::new(
896 vec![
897 ScriptedProvider::tool_call("t1", "bash", json!({"command": "sleep 0.2"})),
898 ScriptedProvider::text_reply("first done"),
899 ScriptedProvider::text_reply("second done"),
900 ],
901 cfg(),
902 );
903 h.handle.prompt("first".into()).await;
904 h.handle.prompt("second".into()).await;
906 let first = h.wait_for_outcome().await;
907 assert_eq!(
908 first,
909 Outcome::Done {
910 text: "first done".into()
911 }
912 );
913 let second = h.wait_for_outcome().await;
914 assert_eq!(
915 second,
916 Outcome::Done {
917 text: "second done".into()
918 }
919 );
920 assert!(h.seen.iter().any(|e| e == "PromptQueued"));
921 }
922
923 #[tokio::test]
924 async fn doom_loop_stops_on_deny() {
925 let scripts: Vec<_> = (0..5)
926 .map(|_| ScriptedProvider::tool_call("t", "read", json!({"path": "/same"})))
927 .collect();
928 let mut h = Harness::new(
929 scripts,
930 EngineConfig {
931 max_turns: 10,
932 ..Default::default()
933 },
934 );
935 h.ask_reply = AskReply::Deny { message: None };
936 let outcome = h.prompt_and_wait("go").await;
937 assert!(
938 matches!(outcome, Outcome::DoomLoop { .. }),
939 "got {outcome:?}"
940 );
941 assert!(h.entries().iter().any(|e| matches!(
942 &e.payload,
943 hotl_types::EntryPayload::Cancelled { reason } if reason.contains("doom")
944 )));
945 }
946
947 #[tokio::test]
948 async fn fallback_model_on_availability_error() {
949 let mut h = Harness::new(
950 vec![
951 vec![Err(ProviderError::Transport("connection reset".into()))],
952 ScriptedProvider::text_reply("served by fallback"),
953 ],
954 EngineConfig {
955 fallback_models: vec!["backup-model".into()],
956 ..cfg()
957 },
958 );
959 let outcome = h.prompt_and_wait("hi").await;
960 assert_eq!(
961 outcome,
962 Outcome::Done {
963 text: "served by fallback".into()
964 }
965 );
966 assert!(h
967 .seen
968 .iter()
969 .any(|e| e.contains("FallbackModel(backup-model)")));
970 let reqs = h.provider.requests();
971 assert_eq!(reqs[1].model, "backup-model");
972 }
973
974 #[tokio::test]
975 async fn auth_error_does_not_fall_back() {
976 let mut h = Harness::new(
977 vec![vec![Err(ProviderError::Auth("bad key".into()))]],
978 EngineConfig {
979 fallback_models: vec!["backup".into()],
980 ..cfg()
981 },
982 );
983 let outcome = h.prompt_and_wait("hi").await;
984 assert!(matches!(outcome, Outcome::Error { .. }));
985 assert!(!h.seen.iter().any(|e| e.contains("FallbackModel")));
986 }
987
988 #[tokio::test]
989 async fn tool_failure_budget_stops_turn() {
990 let scripts: Vec<_> = (0..6)
992 .map(|i| {
993 ScriptedProvider::tool_call(
994 &format!("t{i}"),
995 "read",
996 json!({"path": format!("/nope{i}")}),
997 )
998 })
999 .collect();
1000 let mut h = Harness::new(
1001 scripts,
1002 EngineConfig {
1003 max_turns: 10,
1004 tool_failure_budget: 3,
1005 ..Default::default()
1006 },
1007 );
1008 let outcome = h.prompt_and_wait("read them all").await;
1009 assert_eq!(
1010 outcome,
1011 Outcome::ToolFailureBudget {
1012 tool: "read".into()
1013 }
1014 );
1015 let items = h.items();
1017 let with_feedback = items.iter().any(|i| matches!(
1018 i, Item::ToolResults { results } if results.iter().any(|r| r.content.contains("<retry attempts_left="))
1019 ));
1020 assert!(with_feedback);
1021 }
1022
1023 #[tokio::test]
1024 async fn max_turns_caps_runaway() {
1025 let scripts: Vec<_> = (0..10)
1026 .map(|i| {
1027 ScriptedProvider::tool_call(
1031 &format!("t{i}"),
1032 "bash",
1033 json!({"command": format!("echo {i}")}),
1034 )
1035 })
1036 .collect();
1037 let mut h = Harness::new(
1038 scripts,
1039 EngineConfig {
1040 max_turns: 3,
1041 ..Default::default()
1042 },
1043 );
1044 let outcome = h.prompt_and_wait("loop").await;
1045 assert_eq!(outcome, Outcome::TurnLimit);
1046 }
1047
1048 #[tokio::test]
1049 async fn negative_max_turns_never_caps() {
1050 let mut scripts: Vec<_> = (0..30)
1054 .map(|i| {
1055 ScriptedProvider::tool_call(
1056 &format!("t{i}"),
1057 "bash",
1058 json!({"command": format!("echo {i}")}),
1059 )
1060 })
1061 .collect();
1062 scripts.push(ScriptedProvider::text_reply("finished"));
1063 let mut h = Harness::new(
1064 scripts,
1065 EngineConfig {
1066 max_turns: -1,
1067 ..Default::default()
1068 },
1069 );
1070 let outcome = h.prompt_and_wait("go as long as it takes").await;
1071 assert_eq!(
1072 outcome,
1073 Outcome::Done {
1074 text: "finished".into()
1075 }
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn interrupt_cancels_running_turn() {
1081 let mut h = Harness::new(
1082 vec![ScriptedProvider::tool_call(
1083 "t1",
1084 "bash",
1085 json!({"command": "sleep 30"}),
1086 )],
1087 cfg(),
1088 );
1089 h.handle.prompt("run forever".into()).await;
1090 loop {
1092 let ev =
1093 tokio::time::timeout(std::time::Duration::from_secs(5), h.handle.events.recv())
1094 .await
1095 .expect("timeout")
1096 .expect("closed");
1097 h.seen.push(format!("{ev:?}"));
1098 match ev {
1099 EngineEvent::Ask { reply, .. } => {
1100 let _ = reply.send(AskReply::Allow);
1101 }
1102 EngineEvent::ToolStart { .. } => break,
1103 _ => {}
1104 }
1105 }
1106 h.handle.interrupt();
1107 let outcome = h.wait_for_outcome().await;
1108 assert_eq!(outcome, Outcome::Cancelled);
1109 }
1110
1111 #[tokio::test]
1116 async fn a_turn_flushes_exactly_one_ledger_report_matching_its_samples() {
1117 let mut h = Harness::new(
1118 vec![
1119 ScriptedProvider::tool_call("t1", "read", json!({"path": "x"})),
1120 ScriptedProvider::text_reply("done"),
1121 ],
1122 cfg(),
1123 );
1124 let outcome = h.prompt_and_wait("read x then answer").await;
1125 assert_eq!(
1126 outcome,
1127 Outcome::Done {
1128 text: "done".into()
1129 }
1130 );
1131 assert_eq!(
1132 h.ledger_reports.len(),
1133 1,
1134 "exactly one LedgerReport must be flushed per turn"
1135 );
1136 let report = &h.ledger_reports[0];
1137 assert_eq!(
1138 report.sample_count,
1139 h.provider.request_count(),
1140 "the ledger's sample count must match the samples actually taken"
1141 );
1142 for (i, sample) in report.samples.iter().enumerate() {
1143 let start = sample[hotl_engine::Phase::BoundaryStart as usize];
1144 let end = sample[hotl_engine::Phase::BoundaryEnd as usize];
1145 assert!(
1146 end >= start,
1147 "sample {i}: BoundaryEnd ({end}) must be >= BoundaryStart ({start})"
1148 );
1149 }
1150 }
1151
1152 #[tokio::test]
1161 async fn batch_proposed_and_watermark_durable_track_each_samples_own_final_commit() {
1162 let mut h = Harness::new(
1163 vec![
1164 ScriptedProvider::tool_call("t1", "read", json!({"path": "x"})),
1165 ScriptedProvider::text_reply("done"),
1166 ],
1167 cfg(),
1168 );
1169 h.prompt_and_wait("read x then answer").await;
1170 let report = &h.ledger_reports[0];
1171 assert_eq!(report.sample_count, 2);
1172
1173 let tool_sample = &report.samples[0];
1178 let tools_joined = tool_sample[hotl_engine::Phase::ToolsJoined as usize];
1179 let batch_proposed = tool_sample[hotl_engine::Phase::BatchProposed as usize];
1180 let watermark_durable = tool_sample[hotl_engine::Phase::WatermarkDurable as usize];
1181 assert_ne!(tools_joined, 0, "sample 0 must have run a tool phase");
1182 assert_ne!(batch_proposed, 0);
1183 assert_ne!(watermark_durable, 0);
1184 assert!(
1185 batch_proposed >= tools_joined,
1186 "BatchProposed ({batch_proposed}) must be >= ToolsJoined ({tools_joined}) — \
1187 it must track the tool-results commit, not the earlier model commit"
1188 );
1189 assert!(
1190 watermark_durable >= batch_proposed,
1191 "WatermarkDurable ({watermark_durable}) must be >= BatchProposed ({batch_proposed})"
1192 );
1193
1194 let text_sample = &report.samples[1];
1199 assert_eq!(text_sample[hotl_engine::Phase::ToolsSpawned as usize], 0);
1200 assert_eq!(text_sample[hotl_engine::Phase::ToolsJoined as usize], 0);
1201 assert_ne!(
1202 text_sample[hotl_engine::Phase::BatchProposed as usize],
1203 0,
1204 "a no-tool sample's own commit must still be captured"
1205 );
1206 assert_ne!(
1207 text_sample[hotl_engine::Phase::WatermarkDurable as usize],
1208 0
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn transcript_normalization_is_deterministic() {
1214 let make = || async {
1215 let mut h = Harness::new(vec![ScriptedProvider::text_reply("stable")], cfg());
1216 h.prompt_and_wait("say something stable").await;
1217 h.transcript()
1218 };
1219 let (a, b) = (make().await, make().await);
1220 assert_eq!(
1221 a, b,
1222 "normalized transcripts must be byte-identical across runs"
1223 );
1224 }
1225
1226 fn cfg_with(mode: hotl_engine::AckMode) -> EngineConfig {
1229 EngineConfig {
1230 ack_mode: mode,
1231 ..cfg()
1232 }
1233 }
1234
1235 struct Dawdle;
1242
1243 impl hotl_tools::Tool for Dawdle {
1244 fn name(&self) -> &'static str {
1245 "dawdle"
1246 }
1247 fn description(&self) -> &str {
1248 "waits, then answers"
1249 }
1250 fn schema(&self) -> serde_json::Value {
1251 json!({"type": "object"})
1252 }
1253 fn permission(&self, _: &serde_json::Value) -> hotl_tools::Permission {
1254 hotl_tools::Permission::None
1255 }
1256 fn read_only(&self) -> bool {
1257 true
1258 }
1259 fn run<'a>(
1260 &'a self,
1261 _input: serde_json::Value,
1262 _cancel: tokio_util::sync::CancellationToken,
1263 ) -> futures_util::future::BoxFuture<'a, hotl_tools::ToolOutcome> {
1264 Box::pin(async {
1265 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1266 hotl_tools::ToolOutcome::ok("waited")
1267 })
1268 }
1269 }
1270
1271 fn dawdle_registry() -> Registry {
1272 let mut reg = Registry::builtin();
1273 reg.register(Box::new(Dawdle));
1274 reg
1275 }
1276
1277 async fn steered_tool_turn(mode: hotl_engine::AckMode) -> Harness {
1285 let mut h = Harness::with_items_and_registry(
1286 vec![
1287 ScriptedProvider::tool_call("t1", "dawdle", json!({})),
1288 ScriptedProvider::text_reply("Done, and noted your steer."),
1289 ],
1290 cfg_with(mode),
1291 anchored_history(),
1292 dawdle_registry(),
1293 );
1294 h.steer_on_tool_start = Some("also check the README".into());
1295 let outcome = h.prompt_and_wait("run the thing").await;
1296 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
1297 h
1298 }
1299
1300 #[tokio::test]
1305 async fn a_steer_between_pipelined_commits_lands_after_the_drained_batch() {
1306 let pipelined = steered_tool_turn(hotl_engine::AckMode::Pipelined).await;
1307
1308 let items = pipelined.items();
1309 let results = items
1310 .iter()
1311 .position(|i| matches!(i, Item::ToolResults { .. }))
1312 .expect("the batch committed");
1313 let steer = items.iter().position(is_steer).expect("the steer landed");
1314 assert!(
1315 steer > results,
1316 "a steer must never precede the results it did not see: {items:#?}"
1317 );
1318
1319 let sync = steered_tool_turn(hotl_engine::AckMode::Sync).await;
1321 assert_eq!(pipelined.kinds(), sync.kinds());
1322 assert_eq!(
1323 pipelined.transcript(),
1324 sync.transcript(),
1325 "pipelining must not reorder entries"
1326 );
1327 }
1328
1329 #[tokio::test]
1334 async fn both_ack_modes_produce_the_same_normalized_transcript() {
1335 let run = |mode| async move {
1336 let mut h = Harness::with_registry(
1337 vec![
1338 ScriptedProvider::tool_call("t1", "dawdle", json!({})),
1339 ScriptedProvider::text_reply("waited, as asked"),
1340 ],
1341 cfg_with(mode),
1342 dawdle_registry(),
1343 );
1344 h.prompt_and_wait("take your time").await;
1345 h.transcript()
1346 };
1347 assert_eq!(
1348 run(hotl_engine::AckMode::Pipelined).await,
1349 run(hotl_engine::AckMode::Sync).await
1350 );
1351 }
1352
1353 #[tokio::test]
1365 async fn a_steer_lands_after_a_whole_group_and_the_next_request_agrees_with_the_log() {
1366 let mut h = Harness::new(vec![], cfg());
1367 for sub in ["web", "api"] {
1368 let dir = h.dir().join(sub);
1369 std::fs::create_dir_all(&dir).unwrap();
1370 std::fs::write(dir.join("AGENTS.md"), format!("{sub} subproject rules")).unwrap();
1371 std::fs::write(dir.join("page.txt"), "content").unwrap();
1372 }
1373 h.provider.push_script(tool_batch(&[
1376 (
1377 "t1",
1378 "read",
1379 json!({"path": h.dir().join("web/page.txt").to_str().unwrap()}),
1380 ),
1381 (
1382 "t2",
1383 "read",
1384 json!({"path": h.dir().join("api/page.txt").to_str().unwrap()}),
1385 ),
1386 ]));
1387 h.provider
1388 .push_script(ScriptedProvider::text_reply("read both"));
1389 h.steer_on_tool_start = Some("also check the README".into());
1390 let outcome = h.prompt_and_wait("read both pages").await;
1391 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
1392
1393 let logged = h.items();
1395 let last_hint = logged
1396 .iter()
1397 .rposition(|i| {
1398 matches!(
1399 i,
1400 Item::User {
1401 synthetic: Some(SyntheticReason::SubdirInstructions),
1402 ..
1403 }
1404 )
1405 })
1406 .expect("both hints committed");
1407 let steer = logged.iter().position(is_steer).expect("the steer landed");
1408 assert!(
1409 steer > last_hint,
1410 "the steer is logged after the whole proposal: {logged:#?}"
1411 );
1412
1413 let requests = h.provider.requests();
1417 let sampled = &requests[1].items;
1418 assert_eq!(
1419 sampled.as_slice(),
1420 &logged[..sampled.len()],
1421 "the projection must be the log, in the log's order"
1422 );
1423 }
1424
1425 #[tokio::test]
1435 async fn a_multi_entry_pipelined_proposal_spends_fewer_syncs_than_sync_mode() {
1436 async fn run(mode: hotl_engine::AckMode) -> (u64, u64) {
1440 let mut h = Harness::new(vec![], cfg_with(mode));
1441 for sub in ["web", "api"] {
1442 let dir = h.dir().join(sub);
1443 std::fs::create_dir_all(&dir).unwrap();
1444 std::fs::write(dir.join("AGENTS.md"), format!("{sub} rules")).unwrap();
1445 std::fs::write(dir.join("page.txt"), "content").unwrap();
1446 }
1447 h.provider.push_script(tool_batch(&[
1448 (
1449 "t1",
1450 "read",
1451 json!({"path": h.dir().join("web/page.txt").to_str().unwrap()}),
1452 ),
1453 (
1454 "t2",
1455 "read",
1456 json!({"path": h.dir().join("api/page.txt").to_str().unwrap()}),
1457 ),
1458 ]));
1459 h.provider
1460 .push_script(ScriptedProvider::text_reply("read both"));
1461 let outcome = h.prompt_and_wait("read both pages").await;
1462 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
1463 (h.fsync_count(), h.entries().len() as u64)
1464 }
1465
1466 let (sync_syncs, sync_entries) = run(hotl_engine::AckMode::Sync).await;
1467 assert_eq!(
1468 sync_syncs, sync_entries,
1469 "without pipelining every entry pays its own fsync"
1470 );
1471
1472 let (syncs, entries) = run(hotl_engine::AckMode::Pipelined).await;
1473 assert_eq!(entries, sync_entries, "the same session, the same log");
1474 assert!(
1475 syncs < sync_syncs,
1476 "group commit must beat the serial baseline: {syncs} syncs vs {sync_syncs} \
1477 for {entries} entries"
1478 );
1479 }
1480
1481 #[tokio::test]
1489 async fn the_completed_boundary_spends_one_sync_instead_of_two() {
1490 async fn run(mode: hotl_engine::AckMode) -> (u64, Vec<String>) {
1491 let mut h = Harness::new(vec![ScriptedProvider::text_reply("hi")], cfg_with(mode));
1492 let outcome = h.prompt_and_wait("say hi").await;
1493 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
1494 (h.fsync_count(), h.kinds())
1495 }
1496
1497 let (sync_syncs, sync_kinds) = run(hotl_engine::AckMode::Sync).await;
1498 assert_eq!(
1499 sync_kinds,
1500 ["header", "item", "item", "usage"],
1501 "the scenario is header + prompt + the Completed pair"
1502 );
1503 assert_eq!(
1504 sync_syncs, 4,
1505 "entry at a time: header, prompt, assistant, usage — one sync each"
1506 );
1507
1508 let (syncs, kinds) = run(hotl_engine::AckMode::Pipelined).await;
1509 assert_eq!(kinds, sync_kinds, "the same session, the same log");
1510 assert_eq!(
1511 syncs, 3,
1512 "the assistant item and its usage are ONE causal group: 2 syncs become 1"
1513 );
1514 }
1515
1516 #[tokio::test]
1528 async fn a_steer_inside_the_boundary_group_lands_after_the_assistant_item() {
1529 async fn run(mode: hotl_engine::AckMode) -> Harness {
1530 let mut h = Harness::with_paused_completion(
1531 vec![ScriptedProvider::text_reply("the reply")],
1532 cfg_with(mode),
1533 std::time::Duration::from_millis(150),
1534 );
1535 h.steer_on_text_delta = Some("actually, do it differently".into());
1536 let outcome = h.prompt_and_wait("go").await;
1537 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
1538 h
1539 }
1540
1541 let pipelined = run(hotl_engine::AckMode::Pipelined).await;
1542 let items = pipelined.items();
1543 let assistant = items
1544 .iter()
1545 .position(|i| matches!(i, Item::Assistant { .. }))
1546 .expect("the assistant item committed");
1547 let steer = items.iter().position(is_steer).expect("the steer landed");
1548 assert!(
1549 steer > assistant,
1550 "a steer must never precede the reply that could not have seen it: {items:#?}"
1551 );
1552
1553 let sync = run(hotl_engine::AckMode::Sync).await;
1554 assert_eq!(
1555 pipelined.transcript(),
1556 sync.transcript(),
1557 "the boundary group must not reorder anything"
1558 );
1559 }
1560
1561 fn without_moim(req: &hotl_provider::SamplingRequest) -> hotl_provider::SamplingRequest {
1571 hotl_provider::SamplingRequest {
1572 turn_context: Some("<MOIM>".into()),
1573 ..req.clone()
1574 }
1575 }
1576
1577 const CACHE_LOOKBACK: usize = 20;
1589
1590 const MARKER_BUDGET: usize = 4;
1592
1593 fn durable_wire_body(req: &SamplingRequest) -> serde_json::Value {
1600 hotl_provider_anthropic::wire_body(&SamplingRequest {
1601 ephemeral_tail: Arc::new(Vec::new()),
1602 turn_context: None,
1603 ..req.clone()
1604 })
1605 }
1606
1607 fn without_markers(v: &serde_json::Value) -> serde_json::Value {
1614 match v {
1615 serde_json::Value::Object(map) => serde_json::Value::Object(
1616 map.iter()
1617 .filter(|(k, _)| k.as_str() != "cache_control")
1618 .map(|(k, val)| (k.clone(), without_markers(val)))
1619 .collect(),
1620 ),
1621 serde_json::Value::Array(items) => {
1622 serde_json::Value::Array(items.iter().map(without_markers).collect())
1623 }
1624 other => other.clone(),
1625 }
1626 }
1627
1628 fn count_markers(v: &serde_json::Value) -> usize {
1630 match v {
1631 serde_json::Value::Object(map) => {
1632 usize::from(map.contains_key("cache_control"))
1633 + map.values().map(count_markers).sum::<usize>()
1634 }
1635 serde_json::Value::Array(items) => items.iter().map(count_markers).sum(),
1636 _ => 0,
1637 }
1638 }
1639
1640 fn marker_positions(body: &serde_json::Value) -> Vec<usize> {
1645 let mut idx = 0usize;
1646 let mut out = Vec::new();
1647 for msg in body["messages"].as_array().expect("messages") {
1648 for block in msg["content"].as_array().expect("content") {
1649 idx += 1;
1650 if block.get("cache_control").is_some() {
1651 out.push(idx);
1652 }
1653 }
1654 }
1655 out
1656 }
1657
1658 fn marker_ttls_in_prompt_order(body: &serde_json::Value) -> Vec<Option<String>> {
1663 let mut out = Vec::new();
1664 let mut take = |v: &serde_json::Value| {
1665 if let Some(cc) = v.get("cache_control") {
1666 out.push(cc.get("ttl").and_then(|t| t.as_str()).map(String::from));
1667 }
1668 };
1669 for section in ["tools", "system"] {
1670 for entry in body[section].as_array().into_iter().flatten() {
1671 take(entry);
1672 }
1673 }
1674 for msg in body["messages"].as_array().expect("messages") {
1675 for block in msg["content"].as_array().expect("content") {
1676 take(block);
1677 }
1678 }
1679 out
1680 }
1681
1682 fn assert_marker_budget_and_ttl_order(req: &SamplingRequest, label: &str) {
1686 let body = hotl_provider_anthropic::wire_body(req);
1687 let markers = count_markers(&body);
1688 assert!(
1689 markers <= MARKER_BUDGET,
1690 "{label}: {markers} cache_control markers exceeds the API budget of \
1691 {MARKER_BUDGET}:\n{body:#}"
1692 );
1693 let ttls = marker_ttls_in_prompt_order(&body);
1694 if let Some(plain) = ttls.iter().position(Option::is_none) {
1695 assert!(
1696 ttls[plain..].iter().all(|t| t.as_deref() != Some("1h")),
1697 "{label}: a 1h marker follows a plain (5m) one — longer TTLs must \
1698 come first: {ttls:?}"
1699 );
1700 }
1701 }
1702
1703 fn is_structural_prefix(earlier: &serde_json::Value, later: &serde_json::Value) -> bool {
1708 if without_markers(&earlier["system"]) != without_markers(&later["system"])
1709 || without_markers(&earlier["tools"]) != without_markers(&later["tools"])
1710 {
1711 return false;
1712 }
1713 let a = earlier["messages"].as_array().expect("messages");
1714 let b = later["messages"].as_array().expect("messages");
1715 a.len() <= b.len()
1716 && a.iter()
1717 .zip(b)
1718 .all(|(x, y)| without_markers(x) == without_markers(y))
1719 }
1720
1721 fn cache_prefix_breaks(requests: &[SamplingRequest]) -> Vec<usize> {
1725 requests
1726 .windows(2)
1727 .enumerate()
1728 .filter(|(_, pair)| {
1729 !is_structural_prefix(&durable_wire_body(&pair[0]), &durable_wire_body(&pair[1]))
1730 })
1731 .map(|(i, _)| i)
1732 .collect()
1733 }
1734
1735 fn assert_stable_cache_prefix(requests: &[SamplingRequest]) {
1772 assert!(
1773 requests.len() >= 2,
1774 "a cross-request claim needs at least two requests"
1775 );
1776 for (i, req) in requests.iter().enumerate() {
1777 assert_marker_budget_and_ttl_order(req, &format!("request {i}"));
1778 }
1779 let breaks = cache_prefix_breaks(requests);
1780 assert!(
1781 breaks.is_empty(),
1782 "the durable prefix must only ever grow; it changed at {breaks:?}"
1783 );
1784 for (i, pair) in requests.windows(2).enumerate() {
1785 let written: Vec<usize> = std::iter::once(0)
1788 .chain(marker_positions(&durable_wire_body(&pair[0])))
1789 .collect();
1790 let now = marker_positions(&durable_wire_body(&pair[1]));
1791 let deepest = *written.last().expect("0 is always written");
1792
1793 assert!(
1795 now.iter()
1796 .any(|m| *m >= deepest && m - deepest <= CACHE_LOOKBACK),
1797 "requests {i}->{}: nothing in {now:?} can see the deepest entry \
1798 request {i} wrote (block {deepest}) from within the \
1799 {CACHE_LOOKBACK}-block lookback — the history past it re-bills",
1800 i + 1
1801 );
1802
1803 let mut reachable = written.clone();
1805 for p in &now {
1806 let nearest = reachable
1807 .iter()
1808 .copied()
1809 .filter(|q| q <= p)
1810 .max()
1811 .expect("0 is always a candidate");
1812 assert!(
1813 p - nearest <= CACHE_LOOKBACK,
1814 "requests {i}->{}: the marker at block {p} is {} blocks past the \
1815 nearest reachable entry ({reachable:?}) — outside the \
1816 {CACHE_LOOKBACK}-block lookback",
1817 i + 1,
1818 p - nearest
1819 );
1820 reachable.push(*p);
1821 reachable.sort_unstable();
1822 }
1823 }
1824 }
1825
1826 struct Ping;
1831
1832 impl hotl_tools::Tool for Ping {
1833 fn name(&self) -> &'static str {
1834 "ping"
1835 }
1836 fn description(&self) -> &str {
1837 "answers immediately"
1838 }
1839 fn schema(&self) -> serde_json::Value {
1840 json!({"type": "object"})
1841 }
1842 fn permission(&self, _: &serde_json::Value) -> hotl_tools::Permission {
1843 hotl_tools::Permission::None
1844 }
1845 fn read_only(&self) -> bool {
1846 true
1847 }
1848 fn parallel_safe(&self) -> bool {
1849 true
1850 }
1851 fn run<'a>(
1852 &'a self,
1853 _input: serde_json::Value,
1854 _cancel: tokio_util::sync::CancellationToken,
1855 ) -> futures_util::future::BoxFuture<'a, hotl_tools::ToolOutcome> {
1856 Box::pin(async { hotl_tools::ToolOutcome::ok("pong") })
1857 }
1858 }
1859
1860 fn ping_registry() -> Registry {
1861 let mut reg = Registry::builtin();
1862 reg.register(Box::new(Ping));
1863 reg
1864 }
1865
1866 async fn tool_heavy_session(
1870 turns: usize,
1871 calls: usize,
1872 cache_ttl: hotl_provider::CacheTtl,
1873 ) -> Harness {
1874 let ids: Vec<String> = (0..turns * calls).map(|n| format!("t{n}")).collect();
1875 let mut scripts = Vec::new();
1876 for turn in 0..turns {
1877 let batch: Vec<(&str, &str, serde_json::Value)> = (0..calls)
1878 .map(|i| {
1879 let n = turn * calls + i;
1880 (ids[n].as_str(), "ping", json!({ "n": n }))
1881 })
1882 .collect();
1883 scripts.push(tool_batch(&batch));
1884 scripts.push(ScriptedProvider::text_reply("acknowledged"));
1885 }
1886 let mut h = Harness::with_registry(
1887 scripts,
1888 EngineConfig { cache_ttl, ..cfg() },
1889 ping_registry(),
1890 );
1891 for turn in 0..turns {
1892 let outcome = h.prompt_and_wait(&format!("prompt {turn}")).await;
1893 assert!(
1894 matches!(outcome, Outcome::Done { .. }),
1895 "turn {turn}: {outcome:?}"
1896 );
1897 }
1898 h
1899 }
1900
1901 #[tokio::test]
1917 async fn normal_turns_grow_the_prefix_byte_stably() {
1918 let h = tool_heavy_session(3, 12, hotl_provider::CacheTtl::OneHour).await;
1919 let requests = h.provider.requests();
1920 assert_eq!(
1921 requests.len(),
1922 6,
1923 "three turns, two samples each — every speculative dispatch adopted"
1924 );
1925 assert_stable_cache_prefix(&requests);
1926
1927 let last = hotl_provider_anthropic::wire_body(requests.last().expect("requests"));
1930 let marked = marker_positions(&last);
1931 assert!(
1932 marked.len() >= 2,
1933 "the fixture must produce at least one anchor besides the latest \
1934 marker, else claim 4 is trivial: {marked:?}"
1935 );
1936 assert_eq!(
1937 count_markers(&last),
1938 MARKER_BUDGET,
1939 "the deepest request spends the whole budget: {last:#}"
1940 );
1941 assert!(
1942 marker_ttls_in_prompt_order(&last)
1943 .iter()
1944 .any(|t| t.as_deref() == Some("1h")),
1945 "OneHour must actually reach the wire"
1946 );
1947 }
1948
1949 fn done_todo(content: &str) -> hotl_types::Todo {
1953 hotl_types::Todo {
1954 content: content.into(),
1955 status: hotl_types::TodoStatus::Completed,
1956 active_form: None,
1957 }
1958 }
1959
1960 #[tokio::test]
1967 async fn todos_toggle_keeps_prefix_bytes_identical() {
1968 let mut h = Harness::with_registry(
1969 vec![
1970 ScriptedProvider::text_reply("no list yet"),
1971 ScriptedProvider::text_reply("list noted"),
1972 ScriptedProvider::text_reply("list edited"),
1973 ],
1974 cfg(),
1975 ping_registry(),
1976 );
1977 let first = h.prompt_and_wait("first").await;
1978 assert!(matches!(first, Outcome::Done { .. }), "turn 1: {first:?}");
1979 h.handle.set_todos(vec![done_todo("write the suite")]).await;
1980 let second = h.prompt_and_wait("second").await;
1981 assert!(matches!(second, Outcome::Done { .. }), "turn 2: {second:?}");
1982 h.handle
1983 .set_todos(vec![
1984 done_todo("write the suite"),
1985 done_todo("sweep the docs"),
1986 ])
1987 .await;
1988 let third = h.prompt_and_wait("third").await;
1989 assert!(matches!(third, Outcome::Done { .. }), "turn 3: {third:?}");
1990
1991 let requests = h.provider.requests();
1992 assert_eq!(requests.len(), 3, "one sample per turn");
1993 assert_stable_cache_prefix(&requests);
1994
1995 assert!(requests[0].ephemeral_tail.is_empty(), "no list yet");
1998 assert_eq!(requests[1].ephemeral_tail.len(), 1);
1999 assert_eq!(requests[2].ephemeral_tail.len(), 1);
2000 assert_ne!(
2001 requests[1].ephemeral_tail, requests[2].ephemeral_tail,
2002 "the fixture must actually edit the list"
2003 );
2004
2005 for (i, req) in requests.iter().enumerate() {
2006 let full = hotl_provider_anthropic::wire_body(req);
2007 let durable = durable_wire_body(req);
2008 let full_msgs = full["messages"].as_array().expect("messages");
2009 let durable_msgs = durable["messages"].as_array().expect("messages");
2010 assert_eq!(
2012 full_msgs[..durable_msgs.len()],
2013 durable_msgs[..],
2014 "request {i}: the tail disturbed a durable byte or a marker"
2015 );
2016 for msg in &full_msgs[durable_msgs.len()..] {
2018 for block in msg["content"].as_array().expect("content") {
2019 assert!(
2020 block.get("cache_control").is_none(),
2021 "request {i}: the ephemeral tail carries a marker: {block:#}"
2022 );
2023 }
2024 }
2025 }
2026 }
2027
2028 #[tokio::test]
2033 async fn steer_injection_appends_without_prefix_break() {
2034 let h = steered_tool_turn(hotl_engine::AckMode::Pipelined).await;
2035 let requests = h.provider.requests();
2036 assert_eq!(
2037 requests.len(),
2038 3,
2039 "the first sample, the cancelled speculation, and the rebuild"
2040 );
2041 assert_stable_cache_prefix(&requests);
2042
2043 let speculative = &requests[1];
2045 let rebuilt = requests.last().expect("requests");
2046 assert!(
2047 rebuilt.items.iter().any(is_steer),
2048 "the steer must reach the rebuilt request: {:#?}",
2049 rebuilt.items
2050 );
2051 assert!(
2052 speculative.items.len() < rebuilt.items.len(),
2053 "the rebuild must be strictly longer than the speculation it replaced"
2054 );
2055 assert_eq!(
2056 speculative.items[..],
2057 rebuilt.items[..speculative.items.len()],
2058 "the speculative request must be a prefix of its rebuild, byte for byte"
2059 );
2060 }
2061
2062 async fn compacting_session() -> Harness {
2068 let cfg = EngineConfig {
2069 context_window: 1000,
2070 max_turns: 10,
2071 ..Default::default()
2072 };
2073 let scripts = vec![
2074 ScriptedProvider::tool_call(
2075 "t1",
2076 "bash",
2077 json!({"command": format!("echo {}", "A".repeat(1100))}),
2078 ),
2079 tool_call_reporting(
2080 "t2",
2081 "bash",
2082 json!({"command": format!("echo {}", "B".repeat(200))}),
2083 750,
2084 ),
2085 ScriptedProvider::text_reply("GOAL: digest of earlier work"),
2086 ScriptedProvider::text_reply("finished after compaction"),
2087 ];
2088 let mut h = Harness::new(scripts, cfg);
2089 let outcome = h.prompt_and_wait("summarize both outputs").await;
2090 assert_eq!(
2091 outcome,
2092 Outcome::Done {
2093 text: "finished after compaction".into()
2094 }
2095 );
2096 h
2097 }
2098
2099 #[tokio::test]
2105 async fn compaction_is_the_single_sanctioned_discontinuity() {
2106 let h = compacting_session().await;
2107 let all = h.provider.requests();
2108 let session: Vec<SamplingRequest> = all
2111 .iter()
2112 .filter(|r| r.cache != hotl_provider::CachePolicy::Off)
2113 .cloned()
2114 .collect();
2115 assert_eq!(
2116 session.len(),
2117 3,
2118 "two samples before the fold, one continuation after"
2119 );
2120 for (i, req) in session.iter().enumerate() {
2121 assert_marker_budget_and_ttl_order(req, &format!("session request {i}"));
2122 }
2123 assert_eq!(
2124 cache_prefix_breaks(&session),
2125 vec![1],
2126 "exactly one discontinuity, and it is the fold"
2127 );
2128
2129 let before = hotl_provider_anthropic::wire_body(&session[1]);
2130 let after = hotl_provider_anthropic::wire_body(&session[2]);
2131 assert_eq!(
2132 before["system"], after["system"],
2133 "the system segment — and so the entry the system marker wrote — \
2134 must survive the fold byte for byte"
2135 );
2136 assert_eq!(before["tools"], after["tools"], "tools must survive too");
2137 assert_eq!(
2138 after["system"][0]["cache_control"]["type"], "ephemeral",
2139 "…and the continuation must still mark it"
2140 );
2141 }
2142
2143 #[tokio::test]
2148 async fn cache_off_paths_emit_zero_markers() {
2149 let h = compacting_session().await;
2150 let all = h.provider.requests();
2151 let off: Vec<&SamplingRequest> = all
2152 .iter()
2153 .filter(|r| r.cache == hotl_provider::CachePolicy::Off)
2154 .collect();
2155 assert_eq!(
2156 off.len(),
2157 1,
2158 "this scenario has exactly one cache-off path: the summarize call"
2159 );
2160 assert!(
2161 off[0].system.contains("compress"),
2162 "…and it is the summarize call: {}",
2163 off[0].system
2164 );
2165 let body = hotl_provider_anthropic::wire_body(off[0]);
2166 assert!(
2167 !body.to_string().contains("cache_control"),
2168 "a cache-off request must put no marker on the wire: {body:#}"
2169 );
2170 assert!(
2172 all.iter()
2173 .any(|r| count_markers(&hotl_provider_anthropic::wire_body(r)) > 0),
2174 "the fixture must also exercise the marking path"
2175 );
2176 }
2177
2178 fn anchored_history() -> Vec<Item> {
2190 (0..14)
2191 .flat_map(|i| {
2192 vec![
2193 Item::User {
2194 text: format!("earlier request {i}"),
2195 synthetic: None,
2196 },
2197 Item::Assistant {
2198 blocks: vec![json!({"type": "text", "text": format!("earlier reply {i}")})],
2199 },
2200 ]
2201 })
2202 .collect()
2203 }
2204
2205 async fn dawdle_then_reply(mode: hotl_engine::AckMode) -> Harness {
2214 let mut h = Harness::with_items_and_registry(
2215 vec![
2216 tool_batch(&[("t1", "dawdle", json!({})), ("t2", "dawdle", json!({}))]),
2217 ScriptedProvider::text_reply("done"),
2218 ],
2219 cfg_with(mode),
2220 anchored_history(),
2221 dawdle_registry(),
2222 );
2223 let outcome = h.prompt_and_wait("go").await;
2224 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
2225 h
2226 }
2227
2228 async fn dawdle_then_reply_with_todos(mode: hotl_engine::AckMode) -> Harness {
2233 let mut h = Harness::with_items_and_registry(
2234 vec![
2235 tool_batch(&[("t1", "dawdle", json!({})), ("t2", "dawdle", json!({}))]),
2236 ScriptedProvider::text_reply("done"),
2237 ],
2238 cfg_with(mode),
2239 anchored_history(),
2240 dawdle_registry(),
2241 );
2242 h.handle
2243 .set_todos(vec![hotl_types::Todo {
2244 content: "already done".into(),
2245 status: hotl_types::TodoStatus::Completed,
2246 active_form: None,
2247 }])
2248 .await;
2249 let outcome = h.prompt_and_wait("go").await;
2250 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
2251 h
2252 }
2253
2254 fn anchor_count(req: &SamplingRequest) -> usize {
2257 marker_positions(&durable_wire_body(req))
2258 .len()
2259 .saturating_sub(1)
2260 }
2261
2262 fn assert_the_speculated_tail_crossed_an_anchor(requests: &[SamplingRequest]) {
2274 let first = marker_positions(&durable_wire_body(&requests[0]));
2275 let second = marker_positions(&durable_wire_body(&requests[1]));
2276 assert!(
2277 anchor_count(&requests[0]) >= 1,
2278 "the seeded history must already have a rolling anchor: {first:?}"
2279 );
2280 assert!(
2281 anchor_count(&requests[1]) > anchor_count(&requests[0]),
2282 "the speculated tail must cross a stride boundary — a NEW anchor, \
2283 not merely a new latest: {first:?} -> {second:?}"
2284 );
2285 }
2286
2287 #[tokio::test]
2300 async fn an_adopted_request_is_byte_identical_to_the_sequential_rebuild() {
2301 let adopted_run = dawdle_then_reply(hotl_engine::AckMode::Pipelined).await;
2302 let sequential_run = dawdle_then_reply(hotl_engine::AckMode::Sync).await;
2303
2304 let adopted = adopted_run.provider.requests();
2305 let sequential = sequential_run.provider.requests();
2306 assert_eq!(
2307 adopted.len(),
2308 2,
2309 "the optimistic dispatch WAS adopted: a refusal would rebuild, making three"
2310 );
2311 assert_eq!(sequential.len(), 2, "Sync mode never speculates");
2312 assert_the_speculated_tail_crossed_an_anchor(&adopted);
2315 assert_the_speculated_tail_crossed_an_anchor(&sequential);
2316
2317 assert_eq!(
2319 format!("{:?}", without_moim(&adopted[1])),
2320 format!("{:?}", without_moim(&sequential[1])),
2321 "the adopted request must equal the sequential rebuild"
2322 );
2323
2324 assert_eq!(
2326 hotl_provider_anthropic::wire_body(&without_moim(&adopted[1])).to_string(),
2327 hotl_provider_anthropic::wire_body(&without_moim(&sequential[1])).to_string(),
2328 "…and so must the wire body built from it"
2329 );
2330
2331 for req in [&adopted[1], &sequential[1]] {
2335 let moim = req.turn_context.as_deref().expect("MOIM attached");
2336 assert!(moim.contains("sample=\"2\""), "was: {moim}");
2337 }
2338 }
2339
2340 #[tokio::test]
2348 async fn an_adopted_request_with_an_ephemeral_tail_still_equals_the_rebuild() {
2349 let adopted_run = dawdle_then_reply_with_todos(hotl_engine::AckMode::Pipelined).await;
2350 let sequential_run = dawdle_then_reply_with_todos(hotl_engine::AckMode::Sync).await;
2351
2352 let adopted = adopted_run.provider.requests();
2353 let sequential = sequential_run.provider.requests();
2354 assert_eq!(adopted.len(), 2, "the optimistic dispatch WAS adopted");
2355 assert_eq!(sequential.len(), 2, "Sync mode never speculates");
2356 assert_the_speculated_tail_crossed_an_anchor(&adopted);
2357
2358 for req in [&adopted[1], &sequential[1]] {
2360 assert_eq!(
2361 req.ephemeral_tail.len(),
2362 1,
2363 "fixture: the todo reminder must be live"
2364 );
2365 assert!(
2366 !req.items.iter().any(|i| matches!(
2367 i,
2368 Item::User {
2369 synthetic: Some(hotl_types::SyntheticReason::Todos),
2370 ..
2371 }
2372 )),
2373 "`items` must stay durable-only: {:#?}",
2374 req.items
2375 );
2376 }
2377
2378 assert_eq!(
2379 format!("{:?}", without_moim(&adopted[1])),
2380 format!("{:?}", without_moim(&sequential[1])),
2381 "the adopted request must equal the sequential rebuild"
2382 );
2383 assert_eq!(
2384 hotl_provider_anthropic::wire_body(&without_moim(&adopted[1])).to_string(),
2385 hotl_provider_anthropic::wire_body(&without_moim(&sequential[1])).to_string(),
2386 "…and so must the wire body built from it"
2387 );
2388 }
2389
2390 #[tokio::test]
2394 async fn a_mispredicted_speculation_cancels_without_a_trace() {
2395 let mispredicted = steered_tool_turn(hotl_engine::AckMode::Pipelined).await;
2396
2397 assert_eq!(
2398 mispredicted.provider.request_count(),
2399 3,
2400 "the mispredict costs exactly one cancelled request, and nothing else"
2401 );
2402 let deltas = mispredicted
2406 .seen
2407 .iter()
2408 .filter(|e| e.starts_with("TextDelta("))
2409 .count();
2410 assert_eq!(
2411 deltas, 1,
2412 "an un-adopted stream's deltas must never be forwarded: {:?}",
2413 mispredicted.seen
2414 );
2415 assert!(
2418 !mispredicted.kinds().iter().any(|k| k == "cancelled"),
2419 "kinds: {:?}",
2420 mispredicted.kinds()
2421 );
2422
2423 let reqs = mispredicted.provider.requests();
2435 for (label, req) in [
2436 ("the cancelled speculation", &reqs[1]),
2437 ("its sequential rebuild", &reqs[2]),
2438 ] {
2439 assert!(
2440 anchor_count(req) >= 1,
2441 "{label} must plan over a history that already has a rolling \
2442 anchor: {:?}",
2443 marker_positions(&durable_wire_body(req))
2444 );
2445 }
2446
2447 let sequential = steered_tool_turn(hotl_engine::AckMode::Sync).await;
2449 assert_eq!(
2450 sequential.provider.request_count(),
2451 2,
2452 "Sync mode never speculates, so it never mispredicts"
2453 );
2454 assert!(
2455 anchor_count(&sequential.provider.requests()[1]) >= 1,
2456 "the never-speculated rebuild plans over the same anchored history"
2457 );
2458 assert_eq!(mispredicted.transcript(), sequential.transcript());
2459 }
2460
2461 struct LogWatcher(Arc<std::sync::Mutex<Option<std::path::PathBuf>>>);
2465
2466 impl hotl_tools::Tool for LogWatcher {
2467 fn name(&self) -> &'static str {
2468 "watch_log"
2469 }
2470 fn description(&self) -> &str {
2471 "counts durable log lines"
2472 }
2473 fn schema(&self) -> serde_json::Value {
2474 json!({"type": "object"})
2475 }
2476 fn permission(&self, _: &serde_json::Value) -> hotl_tools::Permission {
2477 hotl_tools::Permission::None
2478 }
2479 fn read_only(&self) -> bool {
2480 true
2481 }
2482 fn parallel_safe(&self) -> bool {
2483 true
2484 }
2485 fn run<'a>(
2486 &'a self,
2487 _input: serde_json::Value,
2488 _cancel: tokio_util::sync::CancellationToken,
2489 ) -> futures_util::future::BoxFuture<'a, hotl_tools::ToolOutcome> {
2490 let path = self.0.lock().expect("log path").clone();
2491 Box::pin(async move {
2492 match path.and_then(|p| std::fs::read_to_string(p).ok()) {
2493 Some(text) => hotl_tools::ToolOutcome::ok(text.lines().count().to_string()),
2494 None => hotl_tools::ToolOutcome::err("no log"),
2495 }
2496 })
2497 }
2498 }
2499
2500 async fn watched_batch(calls: &[(&str, &str, serde_json::Value)]) -> Vec<String> {
2501 let cell = Arc::new(std::sync::Mutex::new(None));
2504 let mut registry = Registry::builtin();
2505 registry.register(Box::new(LogWatcher(cell.clone())));
2506 let mut h = Harness::with_registry(
2507 vec![tool_batch(calls), ScriptedProvider::text_reply("done")],
2508 cfg(),
2509 registry,
2510 );
2511 *cell.lock().unwrap() = Some(h.log_path().to_path_buf());
2512 let outcome = h.prompt_and_wait("watch the log").await;
2513 assert!(matches!(outcome, Outcome::Done { .. }), "{outcome:?}");
2514 let items = h.items();
2515 let Item::ToolResults { results } = items
2516 .iter()
2517 .find(|i| matches!(i, Item::ToolResults { .. }))
2518 .expect("results")
2519 else {
2520 unreachable!()
2521 };
2522 results.iter().map(|r| r.content.clone()).collect()
2523 }
2524
2525 #[tokio::test]
2532 async fn the_inline_tool_path_dispatches_only_behind_the_durability_barrier() {
2533 let seen = watched_batch(&[("t1", "watch_log", json!({}))]).await;
2534 assert_eq!(
2535 seen,
2536 vec!["4".to_string()],
2537 "header + prompt + assistant + usage must all be durable before the call runs"
2538 );
2539 }
2540
2541 #[tokio::test]
2542 async fn the_batch_tool_path_dispatches_only_behind_the_durability_barrier() {
2543 let seen = watched_batch(&[
2544 ("t1", "watch_log", json!({})),
2545 ("t2", "watch_log", json!({})),
2546 ])
2547 .await;
2548 assert_eq!(seen, vec!["4".to_string(), "4".to_string()]);
2549 }
2550
2551 fn tool_call_reporting(
2555 id: &str,
2556 name: &str,
2557 input: serde_json::Value,
2558 input_tokens: u64,
2559 ) -> Vec<Result<StreamEvent, ProviderError>> {
2560 let mut script = ScriptedProvider::tool_call(id, name, input);
2561 if let Some(Ok(StreamEvent::Completed { usage, .. })) = script.last_mut() {
2562 usage.input_tokens = input_tokens;
2563 }
2564 script
2565 }
2566
2567 #[tokio::test]
2568 async fn compaction_folds_history_and_continues() {
2569 let h = compacting_session().await;
2572 assert!(
2573 h.seen.iter().any(|e| e == "Compacted(false)"),
2574 "events: {:?}",
2575 h.seen
2576 );
2577
2578 assert!(h.kinds().iter().any(|k| k == "compaction"));
2580 let requests = h.provider.requests();
2581 assert_eq!(requests.len(), 4);
2582 assert!(requests[2].system.contains("compress"));
2584 let continuation = &requests[3];
2586 assert!(matches!(
2587 &continuation.items[0],
2588 Item::User { synthetic: Some(SyntheticReason::CompactionSummary), text }
2589 if text.contains("GOAL: digest of earlier work")
2590 ));
2591 let flat = format!("{:?}", continuation.items);
2592 assert!(
2593 !flat.contains(&"A".repeat(64)),
2594 "folded history must not ride along"
2595 );
2596 assert!(flat.contains(&"B".repeat(64)), "the tail stays verbatim");
2597 }
2598
2599 #[tokio::test]
2600 async fn compaction_floor_survives_summarize_failure() {
2601 let scripts = vec![
2602 ScriptedProvider::tool_call("t1", "bash", json!({"command": "echo start"})),
2603 tool_call_reporting("t2", "bash", json!({"command": "echo more"}), 900),
2604 vec![Err(ProviderError::Transport("summarizer down".into()))],
2606 vec![Err(ProviderError::Transport(
2607 "summarizer still down".into(),
2608 ))],
2609 ScriptedProvider::text_reply("continued on the floor"),
2610 ];
2611 let mut h = Harness::new(
2612 scripts,
2613 EngineConfig {
2614 context_window: 1000,
2615 max_turns: 10,
2616 ..Default::default()
2617 },
2618 );
2619 let outcome = h.prompt_and_wait(&"x".repeat(1200)).await;
2621 assert_eq!(
2622 outcome,
2623 Outcome::Done {
2624 text: "continued on the floor".into()
2625 }
2626 );
2627 assert!(
2628 h.seen.iter().any(|e| e == "Compacted(true)"),
2629 "events: {:?}",
2630 h.seen
2631 );
2632 let degraded = h.entries().iter().any(|e| {
2633 matches!(
2634 &e.payload,
2635 hotl_types::EntryPayload::Compaction { degraded: true, .. }
2636 )
2637 });
2638 assert!(degraded, "the compaction entry records the floor");
2639 }
2640
2641 #[tokio::test]
2642 async fn moim_rides_the_request_but_never_the_log() {
2643 let mut h = Harness::new(vec![ScriptedProvider::text_reply("hi")], cfg());
2644 h.prompt_and_wait("hello").await;
2645 let requests = h.provider.requests();
2646 let tc = requests[0]
2647 .turn_context
2648 .as_deref()
2649 .expect("turn context attached");
2650 assert!(
2651 tc.contains("sample=\"1\"") && tc.contains("context_used="),
2652 "was: {tc}"
2653 );
2654 assert!(
2655 !h.transcript().contains("<turn-context"),
2656 "MOIM must never persist"
2657 );
2658 }
2659
2660 #[tokio::test]
2661 async fn subdir_agents_md_injected_on_first_touch() {
2662 let mut h = Harness::new(vec![], cfg());
2663 let sub = h.dir().join("web");
2664 std::fs::create_dir_all(&sub).unwrap();
2665 std::fs::write(sub.join("AGENTS.md"), "web subproject rules").unwrap();
2666 std::fs::write(sub.join("page.txt"), "content").unwrap();
2667 let path = sub.join("page.txt");
2668 h.provider.push_script(ScriptedProvider::tool_call(
2669 "t1",
2670 "read",
2671 json!({"path": path.to_str().unwrap()}),
2672 ));
2673 h.provider
2674 .push_script(ScriptedProvider::text_reply("read it"));
2675 let outcome = h.prompt_and_wait("read the page").await;
2676 assert!(matches!(outcome, Outcome::Done { .. }));
2677 drop(h.provider.requests());
2678 let hint_items: Vec<_> = h
2679 .items()
2680 .into_iter()
2681 .filter(|i| {
2682 matches!(
2683 i,
2684 Item::User {
2685 synthetic: Some(SyntheticReason::SubdirInstructions),
2686 ..
2687 }
2688 )
2689 })
2690 .collect();
2691 assert_eq!(hint_items.len(), 1, "items: {:#?}", h.items());
2692 let Item::User { text, .. } = &hint_items[0] else {
2693 unreachable!()
2694 };
2695 assert!(text.contains("web subproject rules") && text.contains("trust=\"untrusted\""));
2696 let requests = h.provider.requests();
2698 let flat = format!("{:?}", requests[1].items);
2699 assert!(flat.contains("web subproject rules"));
2700 }
2701
2702 #[tokio::test]
2703 async fn mutating_batches_are_bracketed_by_snapshots() {
2704 let mut h = Harness::new(
2705 vec![
2706 ScriptedProvider::tool_call("t1", "bash", json!({"command": "echo hi"})),
2707 ScriptedProvider::text_reply("done"),
2708 ],
2709 cfg(),
2710 );
2711 h.prompt_and_wait("run it").await;
2712 let labels = h.snapshots.lock().unwrap().clone();
2713 assert_eq!(labels, ["pre batch 1", "post batch 1"]);
2714
2715 let dir = tempfile::tempdir().unwrap();
2717 let file = dir.path().join("f.txt");
2718 std::fs::write(&file, "x").unwrap();
2719 let mut h = Harness::new(
2720 vec![
2721 ScriptedProvider::tool_call("t1", "read", json!({"path": file.to_str().unwrap()})),
2722 ScriptedProvider::text_reply("read"),
2723 ],
2724 cfg(),
2725 );
2726 h.prompt_and_wait("read it").await;
2727 assert!(h.snapshots.lock().unwrap().is_empty());
2728 }
2729
2730 #[tokio::test]
2731 async fn resume_continues_an_interrupted_turn() {
2732 let seeded = vec![Item::User {
2736 text: "half-finished request".into(),
2737 synthetic: None,
2738 }];
2739 let mut h = Harness::with_items(
2740 vec![ScriptedProvider::text_reply(
2741 "finished the interrupted turn",
2742 )],
2743 cfg(),
2744 seeded,
2745 );
2746 assert!(hotl_engine::needs_continuation(&h.items()) || h.items().is_empty());
2747 h.handle.continue_turn().await;
2748 let outcome = h.wait_for_outcome().await;
2749 assert_eq!(
2750 outcome,
2751 Outcome::Done {
2752 text: "finished the interrupted turn".into()
2753 }
2754 );
2755 let user_turns = h.provider.requests()[0]
2758 .items
2759 .iter()
2760 .filter(|i| {
2761 matches!(
2762 i,
2763 Item::User {
2764 synthetic: None,
2765 ..
2766 }
2767 )
2768 })
2769 .count();
2770 assert_eq!(user_turns, 1, "continue must not append a second user item");
2771 }
2772
2773 #[tokio::test]
2774 async fn continue_is_a_noop_on_a_complete_projection() {
2775 let done = vec![
2777 Item::User {
2778 text: "q".into(),
2779 synthetic: None,
2780 },
2781 Item::Assistant {
2782 blocks: vec![json!({"type":"text","text":"a"})],
2783 },
2784 ];
2785 assert!(!hotl_engine::needs_continuation(&done));
2786 }
2787
2788 #[tokio::test]
2789 async fn reset_mode_compaction_drops_the_verbatim_tail() {
2790 let cfg = EngineConfig {
2793 context_window: 1000,
2794 max_turns: 10,
2795 compaction_reset: true,
2796 ..Default::default()
2797 };
2798 let scripts = vec![
2799 ScriptedProvider::tool_call(
2800 "t1",
2801 "bash",
2802 json!({"command": format!("echo {}", "A".repeat(1100))}),
2803 ),
2804 tool_call_reporting(
2805 "t2",
2806 "bash",
2807 json!({"command": format!("echo {}", "B".repeat(200))}),
2808 750,
2809 ),
2810 ScriptedProvider::text_reply("GOAL: digest"),
2811 ScriptedProvider::text_reply("done after reset compaction"),
2812 ];
2813 let mut h = Harness::new(scripts, cfg);
2814 let outcome = h.prompt_and_wait("do the thing").await;
2815 assert_eq!(
2816 outcome,
2817 Outcome::Done {
2818 text: "done after reset compaction".into()
2819 }
2820 );
2821 assert!(h.seen.iter().any(|e| e.starts_with("Compacted")));
2822 let continuation = h.provider.last_request().unwrap();
2823 assert!(continuation.items.iter().any(|i| matches!(
2825 i,
2826 Item::User {
2827 synthetic: Some(SyntheticReason::CompactionSummary),
2828 ..
2829 }
2830 )));
2831 assert!(
2833 !continuation
2834 .items
2835 .iter()
2836 .any(|i| matches!(i, Item::ToolResults { .. } | Item::Assistant { .. })),
2837 "reset-mode continuation must not carry the verbatim tail: {:?}",
2838 continuation.items
2839 );
2840 }
2841
2842 #[tokio::test]
2843 async fn moim_context_pct_can_be_hidden() {
2844 let mut h = Harness::new(
2845 vec![ScriptedProvider::text_reply("hi")],
2846 EngineConfig {
2847 show_context_pct: false,
2848 ..cfg()
2849 },
2850 );
2851 h.prompt_and_wait("hello").await;
2852 let reqs = h.provider.requests();
2853 let tc = reqs[0].turn_context.as_deref().unwrap();
2854 assert!(!tc.contains("context_used"), "pct must be omitted: {tc}");
2855 assert!(tc.contains("sample="), "the rest of MOIM still rides");
2856 }
2857
2858 #[tokio::test]
2859 async fn pre_tool_hook_blocks_a_call() {
2860 use hotl_engine::hooks::{InProcessHooks, PreToolDecision};
2861 let hooks = Arc::new(InProcessHooks::new().on_pre_tool(|name, input| {
2862 if name == "bash" && input.get("command").and_then(|c| c.as_str()) == Some("danger") {
2863 PreToolDecision::Deny {
2864 message: "policy: no danger".into(),
2865 }
2866 } else {
2867 PreToolDecision::Continue
2868 }
2869 }));
2870 let mut h = Harness::with_hooks(
2871 vec![
2872 ScriptedProvider::tool_call("t1", "bash", json!({"command": "danger"})),
2873 ScriptedProvider::text_reply("understood, blocked"),
2874 ],
2875 cfg(),
2876 hooks,
2877 );
2878 let outcome = h.prompt_and_wait("do the dangerous thing").await;
2879 assert!(matches!(outcome, Outcome::Done { .. }));
2880 let blocked = h.items().into_iter().any(|i| matches!(
2882 i, Item::ToolResults { results } if results.iter().any(|r| r.is_error && r.content.contains("policy: no danger"))
2883 ));
2884 assert!(
2885 blocked,
2886 "the hook's denial reached the model as a tool result"
2887 );
2888 }
2889
2890 #[tokio::test]
2891 async fn post_tool_hook_annotates_a_result() {
2892 use hotl_engine::hooks::InProcessHooks;
2893 let dir = tempfile::tempdir().unwrap();
2894 let file = dir.path().join("f.txt");
2895 std::fs::write(&file, "secret content").unwrap();
2896 let hooks = Arc::new(InProcessHooks::new().on_post_tool(|name, _result| {
2897 (name == "read").then(|| "[redacted by hook]".to_string())
2898 }));
2899 let mut h = Harness::with_hooks(
2900 vec![
2901 ScriptedProvider::tool_call("t1", "read", json!({"path": file.to_str().unwrap()})),
2902 ScriptedProvider::text_reply("read the redacted file"),
2903 ],
2904 cfg(),
2905 hooks,
2906 );
2907 h.prompt_and_wait("read it").await;
2908 let redacted = h.items().into_iter().any(|i| matches!(
2909 i, Item::ToolResults { results } if results.iter().any(|r| r.content.contains("[redacted by hook]"))
2910 ));
2911 assert!(
2912 redacted,
2913 "the post-tool hook replaced the result the model saw"
2914 );
2915 assert!(!h.transcript().contains("secret content"));
2917 }
2918
2919 #[tokio::test]
2920 async fn deny_with_message_reaches_the_model() {
2921 let mut h = Harness::new(
2924 vec![
2925 ScriptedProvider::tool_call("t1", "write", json!({"path": "a.md", "content": "x"})),
2926 ScriptedProvider::text_reply("understood, using notes.md"),
2927 ],
2928 cfg(),
2929 );
2930 h.ask_reply = AskReply::Deny {
2931 message: Some("wrong file — use notes.md".into()),
2932 };
2933 let outcome = h.prompt_and_wait("write it").await;
2934 assert!(matches!(outcome, Outcome::Done { .. }));
2935 let items = h.items();
2936 let Item::ToolResults { results } = &items[2] else {
2937 panic!("expected results")
2938 };
2939 assert!(results[0].is_error);
2940 assert!(
2941 results[0]
2942 .content
2943 .contains("declined this tool call: wrong file — use notes.md"),
2944 "the denial reason must reach the model: {}",
2945 results[0].content
2946 );
2947 }
2948
2949 async fn harness_read_then_write() -> Harness {
2950 let dir = tempfile::tempdir().unwrap();
2951 let f = dir.path().join("x.txt");
2952 std::fs::write(&f, "content").unwrap();
2953 let mut h = Harness::new(
2954 vec![
2955 ScriptedProvider::tool_call("t1", "read", json!({"path": f.to_str().unwrap()})),
2956 ScriptedProvider::tool_call(
2957 "t2",
2958 "write",
2959 json!({"path": f.to_str().unwrap(), "content": "new"}),
2960 ),
2961 ScriptedProvider::text_reply("did both"),
2962 ],
2963 EngineConfig {
2964 max_turns: 6,
2965 ..Default::default()
2966 },
2967 );
2968 h.keep_dir(dir);
2969 h.prompt_and_wait("read then write").await;
2970 h
2971 }
2972
2973 #[tokio::test]
2974 async fn trajectory_matches() {
2975 let h = harness_read_then_write().await;
2976 h.assert_trajectory(&["read", "write"], TrajectoryMatch::Exact);
2977 h.assert_trajectory(&["write", "read"], TrajectoryMatch::Unordered);
2978 h.assert_trajectory(&["write"], TrajectoryMatch::Subset);
2979 assert_eq!(h.tool_calls()[0].0, "read");
2980 assert_eq!(h.tool_calls()[1].1["content"], "new");
2981 }
2982
2983 #[tokio::test]
2984 #[should_panic(expected = "trajectory")]
2985 async fn trajectory_exact_rejects_wrong_order() {
2986 let h = harness_read_then_write().await;
2987 h.assert_trajectory(&["write", "read"], TrajectoryMatch::Exact);
2988 }
2989
2990 async fn read_big_with_threshold(threshold: u64) -> String {
2993 let dir = tempfile::tempdir().unwrap();
2994 let big = dir.path().join("big.txt");
2995 std::fs::write(&big, format!("{}\n", "B".repeat(59)).repeat(1000)).unwrap();
3000 let mut h = Harness::new(
3001 vec![
3002 ScriptedProvider::tool_call("t1", "read", json!({"path": big.to_str().unwrap()})),
3003 ScriptedProvider::text_reply("read it"),
3004 ],
3005 EngineConfig {
3006 evict_threshold_tokens: threshold,
3007 max_turns: 6,
3008 ..Default::default()
3009 },
3010 );
3011 h.prompt_and_wait("read the big file").await;
3012 drop(dir);
3013 let items = h.items();
3014 let Item::ToolResults { results } = &items[2] else {
3015 panic!("expected results")
3016 };
3017 if threshold != 0 {
3019 let has_blobs = std::fs::read_dir(h.dir())
3020 .unwrap()
3021 .filter_map(|e| e.ok())
3022 .any(|e| e.path().to_string_lossy().contains(".blobs"));
3023 assert!(
3024 has_blobs,
3025 "a .blobs dir should exist beside the log after eviction"
3026 );
3027 }
3028 results[0].content.clone()
3029 }
3030
3031 #[tokio::test]
3032 async fn oversized_tool_result_is_evicted_to_a_blob() {
3033 let content = read_big_with_threshold(5_000).await;
3034 assert!(content.contains("<evicted"), "result should be evicted");
3035 assert!(content.contains("Read it with the read tool"));
3036 assert!(
3037 content.len() < 5_000,
3038 "in-context result is a preview, not the full 60KB"
3039 );
3040 }
3041
3042 #[tokio::test]
3043 async fn eviction_disabled_at_threshold_zero() {
3044 let content = read_big_with_threshold(0).await;
3045 assert!(
3046 !content.contains("<evicted"),
3047 "threshold 0 disables eviction"
3048 );
3049 assert!(
3050 content.len() > 50_000,
3051 "the full result rides in-context when disabled"
3052 );
3053 }
3054
3055 #[allow(dead_code)]
3056 fn silence_unused(_: StopReason, _: TokenUsage) {}
3057}