Skip to main content

hotl_testkit/
lib.rs

1//! Golden-transcript testkit.
2//!
3//! Scripted completions drive the *real* actor/turn/persistence stack; tests
4//! assert on the normalized persisted transcript — the log is the canon, so
5//! the log is what gets golden-checked. Determinism comes from the commit
6//! protocol: the log fixes exactly one order for every interleaving the
7//! harness can produce.
8
9use 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    /// Debug strings of every event seen, in order.
26    pub seen: Vec<String>,
27    log_path: std::path::PathBuf,
28    _dir: tempfile::TempDir,
29    /// Extra temp dirs whose lifetime must match the harness (scenario
30    /// fixtures the scripted tools read/write) — kept, not leaked.
31    extra_dirs: Vec<tempfile::TempDir>,
32    /// Answer for every Ask event (T1: defaults to Allow).
33    pub ask_reply: AskReply,
34    /// One-shot steer to send when the next ToolStart is observed.
35    pub steer_on_tool_start: Option<String>,
36    /// Labels of every shadow snapshot the engine requested, in order.
37    pub snapshots: Arc<std::sync::Mutex<Vec<String>>>,
38}
39
40/// Records snapshot labels instead of running git.
41struct 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    /// The harness working directory (the engine's `cwd` for subdir hints;
52    /// also a scratch space for files the scripted tools touch).
53    pub fn dir(&self) -> &std::path::Path {
54        self._dir.path()
55    }
56
57    /// Tie a fixture temp dir's lifetime to the harness (it is removed when
58    /// the harness drops, instead of being forgotten and leaked on disk).
59    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    /// Construct a harness with a pre-seeded projection (resume scenarios).
73    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    /// Construct a harness with extension hooks (M5 scenarios).
82    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    /// Construct a harness with a custom tool registry (concurrency probes,
97    /// scripted tools).
98    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    /// Construct a harness with custom permission rules (mode/deny/admin
116    /// scenarios).
117    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    /// Send a prompt and drain events until the turn finishes.
192    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    /// The persisted entry kinds, in order — the coarse golden signature.
223    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    /// The full normalized transcript: ids/parents/timestamps zeroed so runs
236    /// are byte-comparable.
237    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    /// Conversation items as persisted, in order.
268    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    /// (tool name, input) for every persisted assistant tool_use, in log order
279    /// — the *trajectory* a scenario produced.
280    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    /// Assert on the tool-call sequence a scenario produced (not just entry
293    /// kinds). Panics — with both sequences — when the mode's relation fails.
294    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/// How `assert_trajectory` relates the expected names to the actual sequence.
318#[derive(Debug, Clone, Copy)]
319pub enum TrajectoryMatch {
320    /// Exactly this sequence, in order.
321    Exact,
322    /// The same multiset of names, any order.
323    Unordered,
324    /// These names appear in order (an in-order subsequence).
325    Subset,
326}
327
328/// A one-sample script whose assistant turn calls several tools in one batch.
329pub 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
352/// Is `needles` an in-order subsequence of `haystack`?
353fn 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    /// A concurrency probe: each call bumps a shared running-counter and
372    /// records the high-water mark, then waits (bounded) for the mark to hit
373    /// 2. Overlapping calls drive the mark to 2; serial execution never does.
374    /// The mark is monotonic, so a fast partner can't be missed between
375    /// polls, and no cross-call state survives a timeout (unlike a barrier).
376    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        // Both calls passed the barrier — they ran concurrently…
457        assert!(
458            results
459                .iter()
460                .all(|r| !r.is_error && r.content == "overlapped"),
461            "{results:?}"
462        );
463        // …and the paired results keep assistant source order.
464        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        // Neither call may see the other running: both time out at the barrier.
490        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        // write (not bash): the harness runs unsandboxed, and auto mode
505        // deliberately excludes unsandboxed bash — covered by rules tests.
506        // Scripts are pushed after construction so the write targets the
507        // harness tempdir, never the test process cwd.
508        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        // No ask fired; the transcript shows who silenced it.
525        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        // The undo safety net still brackets the batch.
536        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        // Protected by file name; inside the harness tempdir so the approved
546        // write never touches the test process cwd.
547        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        // Golden: the normalized transcript is stable across runs.
611        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        // Turn 1 runs a (slow-ish) bash; the harness steers when ToolStart
639        // appears; sample 2 must include the steer in its request items.
640        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        // The steer is durably recorded with provenance…
656        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        // …and the SECOND sample's request actually contained it (rebase row
672        // of the conflict table: woven into the next sample, not the current).
673        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        // Queue a second prompt immediately (turn is running).
706        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        // Distinct paths so the doom detector (identical sigs) stays quiet.
792        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        // Feedback element present in the failing results.
817        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                // Alternate two calls so neither doom (period ≤3 needs 3 repeats
829                // of a block) nor the failure budget trips first… actually use
830                // successful bash echoes: no failures, distinct args.
831                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        // Wait until the tool starts, then interrupt out-of-band.
861        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    /// A tool-call sample whose Completed reports a chosen input_tokens —
896    /// compaction tests anchor on provider-reported usage (A12b), so the
897    /// script must be able to "report" a nearly-full window.
898    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        // Window 1000 → trigger at 800, tail budget 300. A big first result,
914        // then a sample that "reports" 750 tokens: the next request estimate
915        // crosses 800, so the turn compacts. The plan folds the big early
916        // history and keeps the small recent exchange verbatim.
917        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        // The log records the compaction; the projection was re-pointed.
952        assert!(h.kinds().iter().any(|k| k == "compaction"));
953        let requests = h.provider.requests();
954        assert_eq!(requests.len(), 4);
955        // Request 3 is the summarize call (its own tiny conversation)…
956        assert!(requests[2].system.contains("compress"));
957        // …and the continuation request opens with the digest, tail verbatim.
958        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            // Both summarize attempts fail: the floor placeholder applies.
978            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        // ~1500 estimated tokens of tool results pushes past the 800 trigger.
993        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        // The second sample's request saw the hint.
1070        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        // Read-only batches don't snapshot.
1089        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        // A projection ending in a user turn the model never answered (the
1106        // process died mid-turn). continue_turn re-samples and completes it
1107        // without a fresh prompt (#8).
1108        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        // No new user item was appended — the request the model saw is the
1129        // seeded one, not a duplicate.
1130        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        // Last item is an assistant reply → nothing to continue.
1149        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        // Same overflow setup as the in-place test, but compaction_reset=true:
1164        // the continuation request carries the digest and NO verbatim tail.
1165        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        // The digest is present…
1197        assert!(continuation.items.iter().any(|i| matches!(
1198            i,
1199            Item::User {
1200                synthetic: Some(SyntheticReason::CompactionSummary),
1201                ..
1202            }
1203        )));
1204        // …and no ToolResults / Assistant verbatim tail rode along (fresh slate).
1205        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        // The blocked call became an error tool result carrying the hook message.
1254        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        // The real content never reached the transcript.
1289        assert!(!h.transcript().contains("secret content"));
1290    }
1291
1292    #[tokio::test]
1293    async fn deny_with_message_reaches_the_model() {
1294        // A denial that carries a reason surfaces as the tool-result feedback
1295        // (a steer fused with a "no").
1296        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    /// Read a 60KB file with the given eviction threshold; return the first
1364    /// tool result's content.
1365    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        // The blob (when evicted) lives beside the harness log.
1387        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}