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, 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    /// Debug strings of every event seen, in order.
27    pub seen: Vec<String>,
28    log_path: std::path::PathBuf,
29    _dir: tempfile::TempDir,
30    /// Extra temp dirs whose lifetime must match the harness (scenario
31    /// fixtures the scripted tools read/write) — kept, not leaked.
32    extra_dirs: Vec<tempfile::TempDir>,
33    /// Answer for every Ask event (T1: defaults to Allow).
34    pub ask_reply: AskReply,
35    /// One-shot steer to send when the next ToolStart is observed.
36    pub steer_on_tool_start: Option<String>,
37    /// One-shot steer to send when the next stream delta is observed — i.e.
38    /// *inside* a sample window. Deterministic only with
39    /// [`Harness::with_paused_completion`], which holds the stream open long
40    /// enough for the steer to reach the actor before the sample closes.
41    pub steer_on_text_delta: Option<String>,
42    /// Labels of every shadow snapshot the engine requested, in order.
43    pub snapshots: Arc<std::sync::Mutex<Vec<String>>>,
44    /// Every `EngineEvent::LedgerReport` seen, in order (§S1 instrument).
45    pub ledger_reports: Vec<LedgerSummary>,
46    /// The session log's live `sync_data()` counter — the group-commit
47    /// counter assertions (commit-protocol.md §Test obligations) read it
48    /// after the log itself has moved into the session.
49    fsyncs: Arc<std::sync::atomic::AtomicU64>,
50}
51
52/// Wraps the scripted provider so every stream stalls for `pause` right
53/// before its terminal `Completed` event. That stall is the *sample window*:
54/// with the `Completed` pair committing as a pipelined causal group
55/// (commit-protocol.md §Causal groups), it is the only place a scenario can
56/// deterministically land a steer between "the request went out" and "the
57/// assistant item is durable" — the interleaving 72a6f1b is about.
58struct 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
79/// A wrapper the harness installs between the engine and the scripted
80/// provider — see [`Harness::build_wrapped`].
81type ProviderWrap = Box<dyn FnOnce(Arc<ScriptedProvider>) -> Arc<dyn Provider>>;
82
83/// Records snapshot labels instead of running git.
84struct 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    /// The harness working directory (the engine's `cwd` for subdir hints;
95    /// also a scratch space for files the scripted tools touch).
96    pub fn dir(&self) -> &std::path::Path {
97        self._dir.path()
98    }
99
100    /// Tie a fixture temp dir's lifetime to the harness (it is removed when
101    /// the harness drops, instead of being forgotten and leaked on disk).
102    pub fn keep_dir(&mut self, dir: tempfile::TempDir) {
103        self.extra_dirs.push(dir);
104    }
105
106    /// The session log this harness writes — for scenarios whose scripted
107    /// tools have to read the canon while the turn is still running.
108    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    /// Construct a harness with a pre-seeded projection (resume scenarios).
122    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    /// Construct a harness with extension hooks (M5 scenarios).
131    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    /// Construct a harness with a custom tool registry (concurrency probes,
146    /// scripted tools).
147    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    /// Construct a harness with a pre-seeded projection *and* a custom
156    /// registry. The cache-prefix scenarios need both at once: a history long
157    /// enough that the breakpoint planner has already placed a rolling anchor,
158    /// and the scripted tools whose batches grow it.
159    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    /// Construct a harness with a custom tool registry and the writer's
169    /// `sync_data()` no-op'd (`hotl_store::SessionLog::set_sync_noop`) — the
170    /// §S1 loop-overhead CI gate's scenario, isolating measured overhead
171    /// from real disk sync latency.
172    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    /// Construct a harness whose every sample stalls before completing — see
198    /// [`PausedCompletion`]. Pair with [`Harness::steer_on_text_delta`].
199    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    /// Construct a harness with custom permission rules (mode/deny/admin
219    /// scenarios).
220    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    /// `wrap`, when present, sits between the engine and the scripted
276    /// provider: the harness keeps the `ScriptedProvider` for `requests()`
277    /// and `push_script`, while the engine talks to the wrapper.
278    #[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    /// `sync_data()` calls the session's writer has completed so far.
336    pub fn fsync_count(&self) -> u64 {
337        self.fsyncs.load(std::sync::atomic::Ordering::SeqCst)
338    }
339
340    /// Send a prompt and drain events until the turn finishes.
341    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    /// The persisted entry kinds, in order — the coarse golden signature.
378    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    /// The full normalized transcript: ids/parents/timestamps zeroed so runs
391    /// are byte-comparable.
392    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    /// Conversation items as persisted, in order.
423    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    /// (tool name, input) for every persisted assistant tool_use, in log order
434    /// — the *trajectory* a scenario produced.
435    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    /// Assert on the tool-call sequence a scenario produced (not just entry
448    /// kinds). Panics — with both sequences — when the mode's relation fails.
449    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/// How `assert_trajectory` relates the expected names to the actual sequence.
473#[derive(Debug, Clone, Copy)]
474pub enum TrajectoryMatch {
475    /// Exactly this sequence, in order.
476    Exact,
477    /// The same multiset of names, any order.
478    Unordered,
479    /// These names appear in order (an in-order subsequence).
480    Subset,
481}
482
483/// A one-sample script whose assistant turn calls several tools in one batch.
484pub 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
507/// Is `needles` an in-order subsequence of `haystack`?
508fn 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    /// A concurrency probe: each call bumps a shared running-counter and
527    /// records the high-water mark, then waits (bounded) for the mark to hit
528    /// 2. Overlapping calls drive the mark to 2; serial execution never does.
529    /// The mark is monotonic, so a fast partner can't be missed between
530    /// polls, and no cross-call state survives a timeout (unlike a barrier).
531    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        // Both calls passed the barrier — they ran concurrently…
612        assert!(
613            results
614                .iter()
615                .all(|r| !r.is_error && r.content == "overlapped"),
616            "{results:?}"
617        );
618        // …and the paired results keep assistant source order.
619        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        // Neither call may see the other running: both time out at the barrier.
645        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        // write (not bash): the harness runs unsandboxed, and auto mode
660        // deliberately excludes unsandboxed bash — covered by rules tests.
661        //
662        // The target must be *inside* the working directory: a write outside
663        // it is a protected ask that deliberately outranks `mode=auto` (the
664        // sandbox write-confinement floor does not cover it), and this test is
665        // about the auto tier silencing an *ordinary* call. A scratch dir
666        // created inside the process cwd is both in-tree and self-cleaning, so
667        // the write still never leaves a stray file behind.
668        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        // No ask fired; the transcript shows who silenced it.
689        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        // The undo safety net still brackets the batch.
701        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        // Protected by file name; inside the harness tempdir so the approved
711        // write never touches the test process cwd.
712        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        // A *relative* path: `read` is only unprompted inside the working
730        // directory, and an absolute one is a protected ask that deliberately
731        // outranks `mode=auto`. This test is about auto mode not asking for an
732        // ordinary call, so the call has to be an ordinary one.
733        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        // The fixture is an absolute tempdir path, i.e. outside the working
776        // directory, so the read is a protected ask before it runs. The
777        // pending_ask/ask_resolved pair is part of the golden: it pins that an
778        // out-of-tree read is gated and that the gate is journalled.
779        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        // Golden: the normalized transcript is stable across runs.
794        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        // Turn 1 runs a (slow-ish) bash; the harness steers when ToolStart
822        // appears; sample 2 must include the steer in its request items.
823        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        // The steer is durably recorded with provenance…
839        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        // …and the sample that actually ran next contained it (rebase row of
855        // the conflict table: woven into the next sample, not the current).
856        //
857        // Three requests, not two: the steer lands exactly at the boundary
858        // where the turn optimistically dispatched the next sample, so the
859        // refreshed leaf is the steer's and the speculation is refused. That
860        // costs one cancelled request and nothing else (commit-protocol.md
861        // §Causal groups: "one cancelled request … with no other
862        // consequence") — the last request is the sequential rebuild.
863        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        // Queue a second prompt immediately (turn is running).
905        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        // Distinct paths so the doom detector (identical sigs) stays quiet.
991        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        // Feedback element present in the failing results.
1016        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                // Alternate two calls so neither doom (period ≤3 needs 3 repeats
1028                // of a block) nor the failure budget trips first… actually use
1029                // successful bash echoes: no failures, distinct args.
1030                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        // 30 tool steps — past the old hard-wired 25 — then a real answer.
1051        // A negative `max_turns` is the opt-in "run until the model is done"
1052        // posture, so the turn must reach `Done`, never `TurnLimit`.
1053        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        // Wait until the tool starts, then interrupt out-of-band.
1091        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    /// §S1: a turn flushes exactly one `LedgerReport`, sized to the samples
1112    /// it actually took (a tool-call sample plus the follow-up text-reply
1113    /// sample), and every sample's `BoundaryEnd` is at or after its
1114    /// `BoundaryStart` — the instrument must never invert its own window.
1115    #[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    /// §S1 fix: `BatchProposed`/`WatermarkDurable` must land on the sample's
1153    /// REAL final propose — the tool-results commit when a tool phase runs,
1154    /// the model's own assistant+usage commit otherwise. Before the fix both
1155    /// phases were first-stamp-wins like every other phase, so the
1156    /// tool-results propose in `run_tool_batch` was always a silent no-op:
1157    /// the (ToolsJoined→BatchProposed) delta was provably always zero, and
1158    /// the tool-results commit's own latency was never measured at all
1159    /// (absorbed into WatermarkDurable→BoundaryEnd instead).
1160    #[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        // Sample 0 ran a tool phase: ToolsJoined must be stamped, and the
1174        // sample's final commit (BatchProposed/WatermarkDurable) must land
1175        // AT OR AFTER it — never before, which is what the bug produced
1176        // (both stuck at the model's own, earlier, assistant+usage commit).
1177        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        // Sample 1 had no tool phase: ToolsSpawned/ToolsJoined stay absent,
1195        // but BatchProposed/WatermarkDurable must still be present — the
1196        // model's own assistant+usage commit is that sample's only (and so
1197        // its final) propose.
1198        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    // --- Task 9 (S2b pipelined commits) ------------------------------
1227
1228    fn cfg_with(mode: hotl_engine::AckMode) -> EngineConfig {
1229        EngineConfig {
1230            ack_mode: mode,
1231            ..cfg()
1232        }
1233    }
1234
1235    /// A permission-free call that stays running long enough for a steer to
1236    /// arrive mid-batch. Deliberately path-free and ask-free: the two ack
1237    /// modes are compared on the *normalized transcript*, and a `bash` ask
1238    /// would put a freshly-minted `pending_ask` id (and a tempdir path) into
1239    /// it, which normalization does not erase and which differs per run
1240    /// regardless of ack mode.
1241    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    /// The steer-during-tools scenario, driven through both ack modes.
1278    ///
1279    /// Seeded with [`anchored_history`] so the mispredict path is exercised
1280    /// *with rolling anchors present* — the cancelled speculation and the
1281    /// sequential rebuild both plan over a history that has already crossed a
1282    /// stride boundary. The seed is a projection seed only, so the normalized
1283    /// transcripts these callers compare are unaffected.
1284    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    /// commit-protocol.md test matrix case 6: "Steer between pipelined
1301    /// blocks → the steer is held per 72a6f1b, lands at the boundary after
1302    /// the assistant blocks it did not see, and the normalized transcript
1303    /// matches the same run in `Sync` mode."
1304    #[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        // …and the pipeline changed nothing about what the log says.
1320        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    /// The revision's second counter assertion: "the same session driven
1330    /// through `Sync` and `Pipelined` modes produces the same normalized
1331    /// transcript". A mode switch can only move ids and timestamps, which
1332    /// normalization already erases — anything else is a reordering.
1333    #[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    /// A multi-entry proposal is **one causal group**: one writer message,
1354    /// one ack, and its items applied to the head whole or not at all (S2c).
1355    /// So a held steer released at that ack can only land after the entire
1356    /// group — there is no longer an instant where the first entry is
1357    /// projected and its siblings are still in the writer's queue.
1358    ///
1359    /// Task 9 wrote this against exactly that instant, which the group
1360    /// closes structurally; it is kept, repurposed, because it still pins
1361    /// the observable that instant used to break — the projection the next
1362    /// request is built from agrees with the log, item for item, in the
1363    /// log's order. That is the invariant, whichever mechanism holds it.
1364    #[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        // One batch, two fresh subdirs: tool results + two subdir-instruction
1374        // entries travel as ONE pipelined proposal.
1375        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        // What the log says…
1394        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        // …and what the next sample was built from must agree with it, item
1414        // for item. A projection that ran ahead of the log would order these
1415        // differently even though both contain the same items.
1416        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    /// The revision's first counter assertion, at the engine level, measured
1426    /// against a real baseline rather than a bound that is true by accident:
1427    /// the SAME session in `Sync` mode spends exactly one `sync_data` per
1428    /// entry (queue depth 1, by construction), and `Pipelined` must beat it.
1429    /// (The 3-entries-1-sync property itself is pinned deterministically in
1430    /// hotl-store's
1431    /// `a_queued_batch_syncs_once_and_acks_every_entry_with_its_own_offset`;
1432    /// this is the end-to-end half, where the grouping is a real race against
1433    /// a real writer thread.)
1434    #[tokio::test]
1435    async fn a_multi_entry_pipelined_proposal_spends_fewer_syncs_than_sync_mode() {
1436        // One batch, two fresh subdirs: the tool-results entry plus two
1437        // subdir-instruction entries travel as ONE proposal — three entries
1438        // that a group commit collapses to one sync.
1439        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    // --- Task 10 (S2c causal groups) ---------------------------------
1482
1483    /// commit-protocol.md §Causal groups (a), the revision's own counter
1484    /// assertion: "the `Completed` boundary spends exactly one `sync_data`
1485    /// (not two)". Both counts are deterministic — every commit here is one
1486    /// writer message on an otherwise idle queue, so the only difference
1487    /// between the modes is the boundary pair collapsing 2→1.
1488    #[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    /// commit-protocol.md test matrix case 6, extended to the boundary group:
1517    /// a steer that arrives *inside* the sample window — after the request
1518    /// went out, while the `Completed` group is still in flight — is held,
1519    /// lands after the assistant item it could not have seen, and produces
1520    /// the same normalized transcript as the same run in `Sync` mode.
1521    ///
1522    /// This is the interleaving the boundary group creates: before S2c the
1523    /// pair committed synchronously, so there was no window between "the
1524    /// group was forwarded" and "the group is durable" for a steer to land
1525    /// in. The hold now ends at the *settle*, and the released steer joins
1526    /// the same published head as the group it followed.
1527    #[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    // --- Task 10 (S2c optimistic dispatch) ---------------------------
1562
1563    /// A request with its MOIM turn-context block replaced by a constant.
1564    ///
1565    /// MOIM is the **sanctioned ephemeral suffix** (commit-protocol.md
1566    /// §Causal groups): it carries a wall-clock timestamp and the working
1567    /// directory, is regenerated on both paths, rides after the cache marker
1568    /// and never persists. Everything else is what byte-identity is claimed
1569    /// over.
1570    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    // --- Task 6 (Phase 5): cross-request cache-prefix stability -------
1578    //
1579    // Byte-identity between two builds of the *same* request is not enough:
1580    // the todo-marker bug this effort exists to close passed every such test,
1581    // because both build paths marked the same wrong block. The claims below
1582    // are about CONSECUTIVE requests — where markers land, and whether the
1583    // next request can still see the entry the last one wrote.
1584
1585    /// The API's cache lookback, in wire content blocks. A marker further than
1586    /// this past the nearest entry the previous request wrote cannot see it,
1587    /// and the segment before it re-bills at write price.
1588    const CACHE_LOOKBACK: usize = 20;
1589
1590    /// The API's per-request `cache_control` budget (a fifth is a 400).
1591    const MARKER_BUDGET: usize = 4;
1592
1593    /// The request as the wire would carry it with **only its durable half**:
1594    /// ephemeral tail emptied, MOIM dropped. Built by re-serializing the
1595    /// `SamplingRequest` (the `without_moim` pattern), never by fishing
1596    /// messages back out of a built body — a tail user message is
1597    /// byte-indistinguishable from a durable one once it is JSON, which is
1598    /// precisely why the split had to happen upstream of the serializer.
1599    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    /// Every `cache_control` key removed, recursively.
1608    ///
1609    /// Markers are *metadata*: they are not part of the prefix the API hashes,
1610    /// so moving one between requests does not invalidate an entry. "The same
1611    /// conversation, grown" therefore has to compare equal whether or not an
1612    /// anchor rolled — which is exactly what makes anchor-rolling safe.
1613    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    /// Every `cache_control` in a built body, counted structurally.
1629    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    /// 1-based wire content-block index of every marker in `messages` — the
1641    /// coordinate space the lookback counts in. The prefix marker (system, or
1642    /// the last tool def) sits *before* block 1 and is represented by the
1643    /// implicit start position 0.
1644    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    /// Every marker's `ttl` (`None` = plain, i.e. the API's default 5m), in
1659    /// the order the API parses the prompt: tools, then system, then messages
1660    /// in wire order. That order is what "longer TTL before shorter" is a
1661    /// claim about.
1662    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    /// Claims 2 and 3, held over one request: the marker budget, and TTL
1683    /// ordering (the API requires longer-lived breakpoints to precede
1684    /// shorter-lived ones).
1685    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    /// Is `earlier`'s durable body a **structural prefix** of `later`'s: the
1704    /// same system, the same tools, and every message `earlier` carried still
1705    /// present, unchanged, at the same index? Markers are ignored (see
1706    /// [`without_markers`]) — a rolled anchor is growth, not a rewrite.
1707    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    /// Indices `i` where request `i + 1` broke the prefix relation with
1722    /// request `i`. Empty for an ordinary session; exactly one across a
1723    /// compaction fold, which is the only sanctioned discontinuity.
1724    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    /// THE suite. Over a sequence of requests one session actually issued:
1736    ///
1737    /// 1. every request's durable body is a structural prefix of the next's;
1738    /// 2. no request exceeds the `cache_control` budget;
1739    /// 3. no plain marker precedes a `1h` one in prompt order;
1740    /// 4. the lookback guarantee, in its two halves — (4a) the **deepest**
1741    ///    entry request N wrote is reachable from a marker in request N+1
1742    ///    (else the longest cached prefix N+1 can find is shallower than the
1743    ///    one N just paid to write, and everything past it re-bills — this is
1744    ///    the billing claim); and (4b) no marker is more than a lookback past
1745    ///    the nearest marker at or before it, counting request N's markers,
1746    ///    request N+1's own shallower markers, and the start.
1747    ///
1748    /// Claim 4 is the one a byte-identity test cannot make: it is a statement
1749    /// about two *different* requests, and it is what the original bug broke.
1750    ///
1751    /// Claim 4a is therefore not a universal property of the planner: a
1752    /// fixture whose single turn appends ≥3 stride crossings at once (a
1753    /// ~45+ block user-role turn) will fail it even though the code is
1754    /// behaving exactly as designed — see the budget-exhaustion note in
1755    /// `cache_plan`'s module doc. That one request legitimately re-bills its
1756    /// history once and self-heals on the next sample, so such a fixture
1757    /// needs its own assertion, not this one.
1758    ///
1759    /// (4b) deliberately admits N+1's own shallower markers as chain links.
1760    /// Not because a deeper breakpoint can *read* one — within a single
1761    /// request the lookup runs against entries that already existed, and
1762    /// nothing can read an entry this same request is still creating. The
1763    /// reason is that the content between N+1's own markers is **new**: it has
1764    /// to be written this request no matter where the markers sit, so there is
1765    /// no miss to price. What matters is the state afterwards — entries now
1766    /// exist at both positions, so the chain N+2 walks back through is intact.
1767    /// Requiring every marker to reach a marker of N alone would fail on
1768    /// correct code: a request that adds an anchor deeper than anything the
1769    /// previous request marked is exactly what a growing history is supposed
1770    /// to do.
1771    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            // The start (0) is always an entry: the prefix marker on
1786            // system/tools seals everything before block 1.
1787            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            // (4a) the billing claim.
1794            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            // (4b) no marker outruns the chain.
1804            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    /// An instant, permission-free, parallel-safe tool. The cache scenarios
1827    /// need *wide* turns (many `tool_use` blocks, many `tool_result` blocks)
1828    /// to reach a stride crossing at all, and `Dawdle`'s deliberate stall
1829    /// would turn a four-turn scenario into a multi-second test for nothing.
1830    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    /// `turns` prompts, each one wide `ping` batch followed by a text reply.
1867    /// Every call carries a distinct id and input so the doom detector has no
1868    /// repeating signature to trip on.
1869    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    /// The ordinary case, and the one the effort is *for*: a tool-heavy
1902    /// session whose history outgrows the anchor stride several times over.
1903    /// Every consecutive pair of requests keeps the durable prefix byte-stable
1904    /// and every marker inside the lookback, so each sample reads the entry
1905    /// the previous sample wrote instead of re-billing the history.
1906    ///
1907    /// Driven at the interactive 1h TTL — the mode this effort ships for
1908    /// `hotl tui`/`acp`, and the only one that makes the TTL-ordering claim
1909    /// non-vacuous (under `FiveMinutes` every marker renders plain).
1910    ///
1911    /// Twelve calls per batch, not three: a turn has to grow the history by
1912    /// more than the lookback for claim 4 to have teeth at all. At this width,
1913    /// deleting the rolling anchors fails the assertion; at a narrow width the
1914    /// latest marker alone would still land inside the lookback and the suite
1915    /// would pass a session that re-bills its whole history.
1916    #[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        // Non-vacuous: the history really did outgrow the stride, so rolling
1928        // anchors exist, the budget is actually spent, and 1h reached the wire.
1929        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    /// A completed todo, so the reminder renders (the list is non-empty) while
1950    /// the TodoGate — a different subsystem, with its own tests — never fires
1951    /// and never adds samples this scenario would have to script for.
1952    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    /// The live billing bug, stated across requests. The `<todos>` reminder's
1961    /// bytes change whenever the list is edited; the original bug marked it,
1962    /// so every edit wrote a cache entry nothing ever read and re-billed the
1963    /// whole history at write price. With the reminder in the ephemeral tail
1964    /// the durable prefix is unmoved by any of it: no list → a list → an
1965    /// edited list changes only the appended, unmarked tail message.
1966    #[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        // Non-vacuous: three genuinely different tails — absent, then two
1996        // different renders of the same reminder.
1997        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            // The durable region, markers included, is untouched by the tail.
2011            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            // …and nothing after it carries a marker.
2017            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    /// A steer landing at a sample boundary is an **append**, not a rewrite:
2029    /// the cancelled speculative request is a strict prefix of the sequential
2030    /// rebuild that replaces it, so no cache entry the session already wrote
2031    /// is orphaned by the interruption.
2032    #[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        // Non-vacuous: the steer really did land, and it landed as an append.
2044        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    /// A session driven past its compaction trigger. Window 1000 → trigger at
2063    /// 800, tail budget 300: a big first tool result, then a sample that
2064    /// "reports" 750 tokens, so the next estimate crosses the trigger and the
2065    /// turn folds. Shared by the golden fold test and the two cache claims
2066    /// about it, so all three are talking about the same fold.
2067    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    /// Compaction is the ONE place the durable prefix is allowed to change out
2100    /// from under the cache: the fold rewrites history, so the entries behind
2101    /// it are dead by construction. What must survive is the *prefix* segment
2102    /// — system + tools, sealed by the system marker — since that is the part
2103    /// a fold does not touch and the part most expensive to rebuild.
2104    #[tokio::test]
2105    async fn compaction_is_the_single_sanctioned_discontinuity() {
2106        let h = compacting_session().await;
2107        let all = h.provider.requests();
2108        // The summarize call is its own tiny conversation on the cache-off
2109        // path; the session's own requests are what the prefix claim is about.
2110        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    /// `CachePolicy::Off` is a policy, not a hint. The compaction summarize
2144    /// call is the production path that uses it: a one-shot conversation that
2145    /// will never be sampled against again, so a marker on it would be a write
2146    /// nothing can ever read.
2147    #[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        // Non-vacuous: the same run's session requests do mark.
2171        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    /// A synthetic history long enough that the breakpoint planner has already
2179    /// placed a rolling anchor before the scenario's own turn starts — 14
2180    /// user/assistant pairs, so 28 wire blocks and a crossing at block 15.
2181    ///
2182    /// Case 9's byte-identity claim is only interesting where the two build
2183    /// paths have anchors to disagree about, and it is strongest where the
2184    /// speculated tail crosses the *next* stride boundary: the sample-2
2185    /// request carries an anchor the sample-1 request did not.
2186    ///
2187    /// Seeded through `initial_items`, so it is a projection seed and never
2188    /// reaches the log — normalized-transcript comparisons are unaffected.
2189    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    /// A path-free tool round trip, so two runs in two tempdirs produce
2206    /// byte-identical histories (a path in a tool input would differ per
2207    /// run for reasons that have nothing to do with adoption).
2208    ///
2209    /// The batch is two calls wide on purpose: with a single `tool_result` the
2210    /// batch's only block *is* the latest marker, and a stride crossing that
2211    /// lands on it is deduped away — there would be no new anchor for the two
2212    /// build paths to disagree about. See [`anchored_history`].
2213    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    /// [`dawdle_then_reply`] with a live ephemeral tail. The todo is
2229    /// **completed** on purpose: the reminder renders (the list is non-empty)
2230    /// but the TodoGate never fires, so the script still needs exactly the two
2231    /// samples adoption is measured over.
2232    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    /// Rolling anchors in a request's plan: every marker in the durable body
2255    /// except the deepest, which is always `latest`.
2256    fn anchor_count(req: &SamplingRequest) -> usize {
2257        marker_positions(&durable_wire_body(req))
2258            .len()
2259            .saturating_sub(1)
2260    }
2261
2262    /// The anchor half of case 9's fixture, asserted rather than assumed: the
2263    /// first request already carries a rolling anchor, and the turn's tool
2264    /// batch crosses the next stride boundary, so the second request carries
2265    /// an anchor the first did not. Without both, "the adopted request equals
2266    /// the rebuild" is a claim about a history with no anchors in it.
2267    ///
2268    /// Only applies to fixtures whose batch is **at least two calls wide** —
2269    /// see [`dawdle_then_reply`]. With a one-call batch the single
2270    /// `tool_result` block is itself `latest`, a crossing landing on it is
2271    /// deduped away, and no new anchor appears; assert
2272    /// [`anchor_count`] directly on such a fixture instead.
2273    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    /// commit-protocol.md test matrix case 9 (MANDATORY): "under leaf
2288    /// equality, the adopted speculative request's bytes equal the
2289    /// sequentially rebuilt request's, byte for byte".
2290    ///
2291    /// The two runs are the same scenario in the two ack modes: `Pipelined`
2292    /// dispatches the second sample optimistically at the boundary and adopts
2293    /// it (proved by the request count — a refusal would show up as a third,
2294    /// rebuilt request), `Sync` issues no ticket and so never speculates,
2295    /// which makes its second request the sequential rebuild by construction.
2296    ///
2297    /// Both levels the brief asks for: the `SamplingRequest` the engine
2298    /// hands the provider, and the JSON body a real dialect puts on the wire.
2299    #[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        // Anchors are in play on both paths — see the helper for why that is
2313        // what makes the byte-identity claim load-bearing rather than trivial.
2314        assert_the_speculated_tail_crossed_an_anchor(&adopted);
2315        assert_the_speculated_tail_crossed_an_anchor(&sequential);
2316
2317        // Level 1: the request the engine hands the provider.
2318        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        // Level 2: the provider-serialized body — the actual bytes.
2325        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        // MOIM itself is regenerated on both paths and still numbers the
2332        // sample it actually belongs to — the speculative build is one
2333        // sample ahead of the sample it was built during.
2334        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    /// Case 9 again, now with a live ephemeral tail — the half the split adds.
2341    ///
2342    /// `Turn::predicted_snapshot` *carries* the tail rather than re-deriving
2343    /// it; if that carry were wrong (dropped, duplicated, or folded back into
2344    /// `items`) the adopted request would stop matching the sequential rebuild
2345    /// here, and only here. Also pins the structural guarantee the later
2346    /// breakpoint planner is built on: `items` is durable-only on BOTH paths.
2347    #[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        // Non-vacuous: there really is an ephemeral tail on both paths.
2359        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    /// The mispredict half of case 9: "the mispredict path (a steer at the
2391    /// boundary) cancels without emitting an entry, without a `Failed`
2392    /// outcome, and produces the same transcript as the sequential path."
2393    #[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        // Un-adopted deltas are ephemeral: they die before any proposal, so
2403        // they never reach the surface. The scripted reply emits exactly one
2404        // TextDelta — seeing two would mean the cancelled stream leaked.
2405        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        // Cancelled ≠ Failed: cancelling an un-adopted stream is not a turn
2416        // outcome and produces no entry — "not even a `cancelled` one".
2417        assert!(
2418            !mispredicted.kinds().iter().any(|k| k == "cancelled"),
2419            "kinds: {:?}",
2420            mispredicted.kinds()
2421        );
2422
2423        // The mispredict path must be exercised WITH rolling anchors present,
2424        // or it proves nothing about the planner. `steered_tool_turn`'s
2425        // `anchored_history` seed is what supplies them, and nothing else in
2426        // this test would fail if that seed shrank — so say it here.
2427        //
2428        // `assert_the_speculated_tail_crossed_an_anchor` does NOT fit this
2429        // fixture: its batch is a single `dawdle` call, so the one
2430        // `tool_result` block *is* `latest` and the crossing that lands on it
2431        // is deduped away — both requests carry exactly one anchor, and the
2432        // helper's "strictly more anchors" half would fail. The claim here is
2433        // only that anchors exist at all.
2434        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        // …and the transcript is the one a never-speculated run produces.
2448        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    /// A tool that reports how much of the session log was on disk at the
2462    /// moment it ran — barrier (a)'s only observable: "no external side
2463    /// effect until the `tool_use` blocks that caused it are durable".
2464    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        // The registry has to exist before the session log does, so the tool
2502        // is handed a cell the harness fills in once the log is created.
2503        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    /// commit-protocol.md §Pipelined commits barrier (a): "no external side
2526    /// effect until the `tool_use` blocks that caused it are durable … and it
2527    /// holds on the inline single-tool execution path exactly as it does on
2528    /// the spawned batch path". By the time a call runs, every entry this
2529    /// turn committed — header, prompt, assistant blocks, usage — is on
2530    /// disk, and the turn holds no unresolved ticket.
2531    #[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    /// A tool-call sample whose Completed reports a chosen input_tokens —
2552    /// compaction tests anchor on provider-reported usage (A12b), so the
2553    /// script must be able to "report" a nearly-full window.
2554    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        // The plan folds the big early history and keeps the small recent
2570        // exchange verbatim — see `compacting_session` for the arithmetic.
2571        let h = compacting_session().await;
2572        assert!(
2573            h.seen.iter().any(|e| e == "Compacted(false)"),
2574            "events: {:?}",
2575            h.seen
2576        );
2577
2578        // The log records the compaction; the projection was re-pointed.
2579        assert!(h.kinds().iter().any(|k| k == "compaction"));
2580        let requests = h.provider.requests();
2581        assert_eq!(requests.len(), 4);
2582        // Request 3 is the summarize call (its own tiny conversation)…
2583        assert!(requests[2].system.contains("compress"));
2584        // …and the continuation request opens with the digest, tail verbatim.
2585        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            // Both summarize attempts fail: the floor placeholder applies.
2605            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        // ~1500 estimated tokens of tool results pushes past the 800 trigger.
2620        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        // The second sample's request saw the hint.
2697        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        // Read-only batches don't snapshot.
2716        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        // A projection ending in a user turn the model never answered (the
2733        // process died mid-turn). continue_turn re-samples and completes it
2734        // without a fresh prompt (#8).
2735        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        // No new user item was appended — the request the model saw is the
2756        // seeded one, not a duplicate.
2757        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        // Last item is an assistant reply → nothing to continue.
2776        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        // Same overflow setup as the in-place test, but compaction_reset=true:
2791        // the continuation request carries the digest and NO verbatim tail.
2792        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        // The digest is present…
2824        assert!(continuation.items.iter().any(|i| matches!(
2825            i,
2826            Item::User {
2827                synthetic: Some(SyntheticReason::CompactionSummary),
2828                ..
2829            }
2830        )));
2831        // …and no ToolResults / Assistant verbatim tail rode along (fresh slate).
2832        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        // The blocked call became an error tool result carrying the hook message.
2881        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        // The real content never reached the transcript.
2916        assert!(!h.transcript().contains("secret content"));
2917    }
2918
2919    #[tokio::test]
2920    async fn deny_with_message_reaches_the_model() {
2921        // A denial that carries a reason surfaces as the tool-result feedback
2922        // (a steer fused with a "no").
2923        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    /// Read a 60KB file with the given eviction threshold; return the first
2991    /// tool result's content.
2992    async fn read_big_with_threshold(threshold: u64) -> String {
2993        let dir = tempfile::tempdir().unwrap();
2994        let big = dir.path().join("big.txt");
2995        // 60KB spread over 1000 lines, not one 60KB line: `read` clips any
2996        // single line at 8KB, so a one-line fixture is no longer a large
2997        // *result* and would never reach the eviction threshold this test is
2998        // about.
2999        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        // The blob (when evicted) lives beside the harness log.
3018        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}