Skip to main content

locode_engine/
lib.rs

1//! locode-engine — the sample→dispatch→append loop and the [`Session`] driving API
2//! (ADR-0005, ADR-0004, ADR-0014).
3//!
4//! A [`Session`] drives one run to a terminal [`locode_protocol::Status`] against any
5//! [`locode_provider::Provider`], dispatching tool calls through a
6//! [`locode_tools::Registry`], emitting `stream-json` events to an [`EventSink`], and
7//! returning one [`locode_protocol::Report`]. Proven end-to-end against
8//! `MockProvider` with zero network.
9
10mod approve;
11mod config;
12mod queue;
13mod run;
14mod session;
15mod sink;
16mod terminal;
17
18pub use approve::{AllowAll, ApprovalRequest, Approver, Decision};
19pub use config::EngineConfig;
20pub use queue::{InputQueue, MID_RUN_PREAMBLE};
21pub use session::Session;
22pub use sink::{EventSink, FnSink, NullSink};
23// The type `Session::cancel_handle` returns (ADR-0018) — re-exported so
24// frontends need no direct tokio-util dependency.
25pub use tokio_util::sync::CancellationToken;
26
27#[cfg(test)]
28mod tests {
29    // Test tools return `&'static str` literals from `description`; the trait ties it
30    // to `&self` so real tools can return a stored field.
31    #![allow(clippy::unnecessary_literal_bound)]
32
33    use super::*;
34    use async_trait::async_trait;
35    use locode_protocol::{
36        ContentBlock, Conversation, Event, Message, ReasoningFormat, Role, Status, Usage,
37        reconstruct_conversation,
38    };
39    use locode_provider::{
40        Completion, ConversationRequest, MockProvider, Provider, ProviderError, StopReason,
41    };
42    use locode_tools::{Registry, Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
43    use serde::Serialize;
44    use serde_json::{Value, json};
45    use std::sync::{Arc, Mutex};
46    use std::time::Duration;
47
48    // ---- trivial in-test tools ----
49
50    #[derive(Serialize)]
51    struct EchoOut {
52        echoed: String,
53    }
54    impl ToolOutput for EchoOut {
55        fn to_prompt_text(&self) -> String {
56            self.echoed.clone()
57        }
58    }
59
60    struct Echo;
61    #[async_trait]
62    impl Tool for Echo {
63        type Args = Value;
64        type Output = EchoOut;
65        fn kind(&self) -> ToolKind {
66            ToolKind::Shell
67        }
68        fn description(&self) -> &str {
69            "echo"
70        }
71        async fn run(&self, _ctx: &ToolCtx, args: Value) -> Result<EchoOut, ToolError> {
72            Ok(EchoOut {
73                echoed: args.to_string(),
74            })
75        }
76    }
77
78    struct Boom;
79    #[async_trait]
80    impl Tool for Boom {
81        type Args = Value;
82        type Output = EchoOut;
83        fn kind(&self) -> ToolKind {
84            ToolKind::Shell
85        }
86        fn description(&self) -> &str {
87            "boom"
88        }
89        async fn run(&self, _ctx: &ToolCtx, _args: Value) -> Result<EchoOut, ToolError> {
90            Err(ToolError::Fatal("boom aborted the turn".into()))
91        }
92    }
93
94    // ---- harness ----
95
96    fn text_turn(text: &str) -> Completion {
97        Completion {
98            content: vec![ContentBlock::Text { text: text.into() }],
99            usage: Usage::default(),
100            stop: StopReason::EndTurn,
101        }
102    }
103
104    fn tool_turn(id: &str, name: &str) -> Completion {
105        Completion {
106            content: vec![ContentBlock::ToolUse {
107                id: id.into(),
108                name: name.into(),
109                input: json!({}),
110            }],
111            usage: Usage::default(),
112            stop: StopReason::ToolUse,
113        }
114    }
115
116    fn config() -> EngineConfig {
117        EngineConfig {
118            session_id: "sess-1".into(),
119            harness: "grok".into(),
120            api_schema: "mock".into(),
121            model: "mock-1".into(),
122            max_turns: None,
123            resample_retries: 2,
124            resample_backoff: Duration::ZERO, // no real sleeps in tests
125            // Project-instruction loading off by default in the loop tests: cwd is the
126            // crate dir, so a live loader would inject this repo's own AGENTS.md and
127            // perturb the exact event/message assertions. The injection path has its own
128            // dedicated tests below (`project_instructions_*`).
129            instructions: locode_instructions::InstructionsConfig {
130                enabled: false,
131                ..Default::default()
132            },
133            ..EngineConfig::default()
134        }
135    }
136
137    /// Build a session with a scripted provider + registry, collecting events.
138    fn session_with(
139        script: Vec<Result<Completion, ProviderError>>,
140        registry: Registry,
141        cfg: EngineConfig,
142    ) -> (Session, Arc<Mutex<Vec<Event>>>) {
143        let events = Arc::new(Mutex::new(Vec::new()));
144        let sink_events = Arc::clone(&events);
145        let sink = Box::new(FnSink(move |event| {
146            sink_events.lock().unwrap().push(event);
147        }));
148        let provider = Arc::new(MockProvider::with_results(script));
149        let session = Session::new(provider, registry, vec![], cfg, sink);
150        (session, events)
151    }
152
153    fn echo_registry() -> Registry {
154        let mut reg = Registry::new();
155        reg.register("echo", Echo);
156        reg
157    }
158
159    fn dump(events: &Arc<Mutex<Vec<Event>>>) -> Vec<Event> {
160        events.lock().unwrap().clone()
161    }
162
163    // ---- terminal-state matrix ----
164
165    #[tokio::test]
166    async fn completed_with_no_tools() {
167        let (mut s, events) =
168            session_with(vec![Ok(text_turn("all done"))], Registry::new(), config());
169        let report = s.run_text("hi").await;
170        assert_eq!(report.status, Status::Completed);
171        assert_eq!(report.final_message.as_deref(), Some("all done"));
172        assert_eq!(report.turns, 1);
173        assert!(report.tool_calls.is_empty());
174        assert_eq!(report.api_schema, "mock");
175        // Init, Message(user), Message(assistant), Result.
176        let evs = dump(&events);
177        assert!(matches!(evs.first(), Some(Event::Init { .. })));
178        assert!(matches!(evs.last(), Some(Event::Result { .. })));
179    }
180
181    // ---- streaming (ADR-0021 slice 1) ----
182
183    #[tokio::test]
184    async fn streaming_run_emits_text_deltas_and_the_whole_message() {
185        let mut cfg = config();
186        cfg.streaming = true;
187        let (mut s, events) = session_with(
188            vec![Ok(text_turn("hello streamed world"))],
189            Registry::new(),
190            cfg,
191        );
192        let report = s.run_text("hi").await;
193        assert_eq!(report.status, Status::Completed);
194        assert_eq!(
195            report.final_message.as_deref(),
196            Some("hello streamed world")
197        );
198
199        let evs = dump(&events);
200        // The deltas concatenate exactly to the finalized assistant text...
201        let delta_text: String = evs
202            .iter()
203            .filter_map(|e| match e {
204                Event::MessageDelta { text } => Some(text.as_str()),
205                _ => None,
206            })
207            .collect();
208        assert_eq!(delta_text, "hello streamed world", "{evs:?}");
209        // ...and there was more than one (proving it actually streamed).
210        let n_deltas = evs
211            .iter()
212            .filter(|e| matches!(e, Event::MessageDelta { .. }))
213            .count();
214        assert!(n_deltas > 1, "expected multiple deltas, got {n_deltas}");
215        // The whole assistant Message is STILL appended — deltas don't replace it.
216        assert!(
217            evs.iter().any(|e| matches!(
218                e,
219                Event::Message { message } if message.role == Role::Assistant
220            )),
221            "whole assistant Message still emitted: {evs:?}"
222        );
223        // Deltas precede the finalized assistant Message in the stream.
224        let first_delta = evs
225            .iter()
226            .position(|e| matches!(e, Event::MessageDelta { .. }))
227            .expect("a delta");
228        let asst_msg = evs
229            .iter()
230            .position(
231                |e| matches!(e, Event::Message { message } if message.role == Role::Assistant),
232            )
233            .expect("assistant message");
234        assert!(
235            first_delta < asst_msg,
236            "deltas come before the whole message"
237        );
238    }
239
240    #[tokio::test]
241    async fn non_streaming_run_emits_no_deltas() {
242        let (mut s, events) =
243            session_with(vec![Ok(text_turn("no stream"))], Registry::new(), config());
244        let _ = s.run_text("hi").await;
245        let evs = dump(&events);
246        assert!(
247            !evs.iter().any(|e| matches!(e, Event::MessageDelta { .. })),
248            "default (non-streaming) run must not emit deltas: {evs:?}"
249        );
250    }
251
252    #[tokio::test]
253    async fn streaming_and_non_streaming_reports_match() {
254        let (mut a, _ea) = session_with(
255            vec![Ok(text_turn("same result"))],
256            Registry::new(),
257            config(),
258        );
259        let mut cfg = config();
260        cfg.streaming = true;
261        let (mut b, _eb) = session_with(vec![Ok(text_turn("same result"))], Registry::new(), cfg);
262        let ra = a.run_text("go").await;
263        let rb = b.run_text("go").await;
264        // Streaming is display-only: the Report is identical either way.
265        assert_eq!(ra.status, rb.status);
266        assert_eq!(ra.final_message, rb.final_message);
267        assert_eq!(ra.turns, rb.turns);
268        assert_eq!(ra.tool_calls.len(), rb.tool_calls.len());
269    }
270
271    #[tokio::test]
272    async fn tool_call_then_complete() {
273        let (mut s, _e) = session_with(
274            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
275            echo_registry(),
276            config(),
277        );
278        let report = s.run_text("go").await;
279        assert_eq!(report.status, Status::Completed);
280        assert_eq!(report.turns, 2);
281        assert_eq!(report.tool_calls.len(), 1);
282        assert!(report.tool_calls[0].ok);
283        assert_eq!(report.tool_calls[0].name, "echo");
284    }
285
286    #[tokio::test]
287    async fn hits_max_turns_after_dispatch() {
288        // Always asks for a tool → never completes; ceiling of 2.
289        let mut cfg = config();
290        cfg.max_turns = Some(2);
291        let (mut s, _e) = session_with(
292            vec![
293                Ok(tool_turn("c1", "echo")),
294                Ok(tool_turn("c2", "echo")),
295                Ok(tool_turn("c3", "echo")),
296            ],
297            echo_registry(),
298            cfg,
299        );
300        let report = s.run_text("go").await;
301        assert_eq!(report.status, Status::MaxTurns);
302        assert_eq!(report.turns, 2);
303        assert_eq!(report.tool_calls.len(), 2);
304    }
305
306    #[tokio::test]
307    async fn model_error_after_bounded_retry() {
308        // Retryable every time → 1 + resample_retries attempts, then ModelError.
309        let script = vec![
310            Err(ProviderError::Transport("reset".into())),
311            Err(ProviderError::Transport("reset".into())),
312            Err(ProviderError::Transport("reset".into())),
313        ];
314        let (mut s, events) = session_with(script, Registry::new(), config());
315        let report = s.run_text("go").await;
316        assert_eq!(report.status, Status::ModelError);
317        assert!(report.error.is_some());
318        assert_eq!(report.turns, 0);
319        // Two non-terminal Error retry notes emitted (resample_retries == 2).
320        let retries = dump(&events)
321            .iter()
322            .filter(|e| matches!(e, Event::Error { .. }))
323            .count();
324        assert_eq!(retries, 2);
325    }
326
327    #[tokio::test]
328    async fn model_error_non_retryable_is_immediate() {
329        let (mut s, events) = session_with(
330            vec![Err(ProviderError::ContextOverflow)],
331            Registry::new(),
332            config(),
333        );
334        let report = s.run_text("go").await;
335        assert_eq!(report.status, Status::ModelError);
336        let retries = dump(&events)
337            .iter()
338            .filter(|e| matches!(e, Event::Error { .. }))
339            .count();
340        assert_eq!(retries, 0, "a non-retryable error must not resample");
341    }
342
343    #[tokio::test]
344    async fn fatal_tool_error_ends_the_run() {
345        let mut reg = Registry::new();
346        reg.register("boom", Boom);
347        let (mut s, _e) = session_with(vec![Ok(tool_turn("c1", "boom"))], reg, config());
348        let report = s.run_text("go").await;
349        assert_eq!(report.status, Status::Error);
350        assert!(report.error.is_some());
351        // The boom call still produced a paired (is_error) record.
352        assert_eq!(report.tool_calls.len(), 1);
353        assert!(!report.tool_calls[0].ok);
354    }
355
356    /// An empty completion (no text, no tool calls — e.g. a reasoning-only
357    /// turn truncated by `max_output_tokens`) is resampled, not labeled
358    /// Completed (ADR-0005 amendment 2026-07-19; grok's `is_empty` rule).
359    #[tokio::test]
360    async fn empty_completion_resamples_then_succeeds() {
361        let empty = Completion {
362            content: vec![ContentBlock::Reasoning {
363                format: ReasoningFormat::Anthropic,
364                text: "thinking only".into(),
365                signature: Some("sig".into()),
366                payload: None,
367            }],
368            usage: Usage::default(),
369            stop: StopReason::MaxTokens,
370        };
371        let (mut session, _events) = session_with(
372            vec![Ok(empty), Ok(text_turn("recovered"))],
373            echo_registry(),
374            config(),
375        );
376        let report = session.run_text("go").await;
377        assert_eq!(report.status, Status::Completed);
378        assert_eq!(report.final_message.as_deref(), Some("recovered"));
379        assert_eq!(report.stop_reason.as_deref(), Some("end_turn"));
380    }
381
382    /// A `max_tokens` stop whose LAST block is a `tool_use` cut that call's
383    /// arguments short. The Anthropic wire surfaces the loss as an empty
384    /// `input`, so dispatching would report a missing required field and blame
385    /// the model; the loop names the real cause instead and keeps going.
386    /// Text typed mid-run rides the tool-result batch of the iteration that
387    /// drains it — **after** the results, which the Responses wire's lowering
388    /// makes load-bearing (ADR-0028).
389    #[tokio::test]
390    async fn queued_input_rides_the_tool_result_batch_after_the_results() {
391        let (mut session, events) = session_with(
392            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
393            echo_registry(),
394            config(),
395        );
396        let queue = session.input_queue();
397        queue.push("actually, use tabs");
398
399        let report = session.run_text("go").await;
400        assert_eq!(report.status, Status::Completed);
401
402        let batch = dump(&events)
403            .into_iter()
404            .find_map(|e| match e {
405                Event::Message { message }
406                    if message.role == Role::User
407                        && message
408                            .content
409                            .iter()
410                            .any(|b| matches!(b, ContentBlock::ToolResult { .. })) =>
411                {
412                    Some(message)
413                }
414                _ => None,
415            })
416            .expect("a tool-result batch was appended");
417
418        let kinds: Vec<&str> = batch
419            .content
420            .iter()
421            .map(|b| match b {
422                ContentBlock::ToolResult { .. } => "result",
423                ContentBlock::Text { .. } => "text",
424                _ => "other",
425            })
426            .collect();
427        assert_eq!(
428            kinds,
429            vec!["result", "text"],
430            "the queued text must follow the results, never precede them"
431        );
432
433        let text = batch
434            .content
435            .iter()
436            .find_map(|b| match b {
437                ContentBlock::Text { text } => Some(text.clone()),
438                _ => None,
439            })
440            .expect("the queued text landed");
441        assert!(
442            text.starts_with(crate::MID_RUN_PREAMBLE),
443            "the mid-run path is marked: {text}"
444        );
445        assert!(text.contains("actually, use tabs"));
446        assert!(queue.is_empty(), "draining consumes");
447    }
448
449    /// A run whose turn emits no tool calls has no batch to carry the text, so
450    /// the item stays queued for the frontend's next-prompt fallback.
451    #[tokio::test]
452    async fn queued_input_with_no_tool_calls_stays_for_the_fallback() {
453        let (mut session, _events) = session_with(
454            vec![Ok(text_turn("nothing to do"))],
455            echo_registry(),
456            config(),
457        );
458        let queue = session.input_queue();
459        queue.push("one more thing");
460
461        let report = session.run_text("go").await;
462        assert_eq!(report.status, Status::Completed);
463        assert_eq!(
464            queue.pending(),
465            vec!["one more thing".to_string()],
466            "no carrier this run — the frontend submits it as an ordinary prompt"
467        );
468    }
469
470    #[tokio::test]
471    async fn truncated_tool_call_is_not_executed_and_names_the_cause() {
472        let truncated = Completion {
473            content: vec![ContentBlock::ToolUse {
474                id: "c1".into(),
475                name: "echo".into(),
476                input: json!({}), // what the wire returns for a cut-off call
477            }],
478            usage: Usage::default(),
479            stop: StopReason::MaxTokens,
480        };
481        let (mut session, events) = session_with(
482            vec![Ok(truncated), Ok(text_turn("smaller this time"))],
483            echo_registry(),
484            config(),
485        );
486        let report = session.run_text("write a huge file").await;
487
488        // Soft: the model gets the error and the loop continues (ADR-0004).
489        assert_eq!(report.status, Status::Completed);
490        assert_eq!(report.final_message.as_deref(), Some("smaller this time"));
491        assert!(
492            report.tool_calls.is_empty(),
493            "a call that never ran is not recorded, matching the cancel path"
494        );
495
496        let explained = dump(&events).iter().any(|e| match e {
497            Event::Message { message } => message.content.iter().any(|b| {
498                matches!(
499                    b,
500                    ContentBlock::ToolResult { tool_use_id, is_error: true, content, .. }
501                        if tool_use_id == "c1"
502                            && content.iter().any(|c| matches!(
503                                c,
504                                locode_protocol::ResultChunk::Text { text }
505                                    if text.contains("output-token limit")
506                                        && text.contains("max_tokens")
507                                        && text.contains("Do not repeat the call unchanged")
508                            ))
509                )
510            }),
511            _ => false,
512        });
513        assert!(
514            explained,
515            "the model must see the truncation, not a 'missing field' decode error"
516        );
517    }
518
519    /// The guard keys off the last **content block**, not the stop reason
520    /// alone: a `tool_use` the model finished before the cut still runs, so
521    /// truncation of a trailing text block never swallows a valid call.
522    #[tokio::test]
523    async fn truncation_after_a_finished_tool_call_still_dispatches() {
524        let cut_after_call = Completion {
525            content: vec![
526                ContentBlock::ToolUse {
527                    id: "c1".into(),
528                    name: "echo".into(),
529                    input: json!({"complete": true}),
530                },
531                ContentBlock::Text {
532                    text: "and then I was cut off mid-sent".into(),
533                },
534            ],
535            usage: Usage::default(),
536            stop: StopReason::MaxTokens,
537        };
538        let (mut session, _events) = session_with(
539            vec![Ok(cut_after_call), Ok(text_turn("done"))],
540            echo_registry(),
541            config(),
542        );
543        let report = session.run_text("go").await;
544
545        assert_eq!(report.status, Status::Completed);
546        assert_eq!(report.tool_calls.len(), 1, "the finished call still ran");
547        assert!(report.tool_calls[0].ok);
548    }
549
550    #[tokio::test]
551    async fn persistent_empty_completions_are_model_error() {
552        let empty = || Completion {
553            content: vec![],
554            usage: Usage::default(),
555            stop: StopReason::MaxTokens,
556        };
557        // resample_retries = 2 → initial + 2 resamples, all empty → ModelError.
558        let (mut session, _events) = session_with(
559            vec![Ok(empty()), Ok(empty()), Ok(empty())],
560            echo_registry(),
561            config(),
562        );
563        let report = session.run_text("go").await;
564        assert_eq!(report.status, Status::ModelError);
565        assert!(
566            report
567                .error
568                .as_deref()
569                .unwrap_or("")
570                .contains("empty completion"),
571            "error names the cause: {:?}",
572            report.error
573        );
574        assert_eq!(report.stop_reason, None, "no completion was accepted");
575    }
576
577    // ---- transcript hygiene ----
578
579    #[tokio::test]
580    async fn mid_batch_abort_synthesizes_results() {
581        // One assistant turn asks for TWO tools: boom (Fatal) then echo. echo must
582        // not run, yet both tool_use ids must be answered in the transcript.
583        let mut reg = Registry::new();
584        reg.register("boom", Boom);
585        reg.register("echo", Echo);
586        let completion = Completion {
587            content: vec![
588                ContentBlock::ToolUse {
589                    id: "c_boom".into(),
590                    name: "boom".into(),
591                    input: json!({}),
592                },
593                ContentBlock::ToolUse {
594                    id: "c_echo".into(),
595                    name: "echo".into(),
596                    input: json!({}),
597                },
598            ],
599            usage: Usage::default(),
600            stop: StopReason::ToolUse,
601        };
602        let (mut s, events) = session_with(vec![Ok(completion)], reg, config());
603        let report = s.run_text("go").await;
604        assert_eq!(report.status, Status::Error);
605
606        // The appended tool-result message pairs BOTH ids.
607        let evs = dump(&events);
608        let answered: Vec<String> = evs
609            .iter()
610            .filter_map(|e| match e {
611                Event::Message { message } if message.role == Role::User => Some(&message.content),
612                _ => None,
613            })
614            .flatten()
615            .filter_map(|b| match b {
616                ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
617                _ => None,
618            })
619            .collect();
620        assert!(answered.iter().any(|id| id == "c_boom"));
621        assert!(
622            answered.iter().any(|id| id == "c_echo"),
623            "the un-run echo must be paired"
624        );
625        // boom recorded (ran, fatal); echo NOT recorded (never executed).
626        assert_eq!(report.tool_calls.len(), 1);
627    }
628
629    // ---- replay + stream fidelity ----
630
631    #[tokio::test]
632    async fn thinking_block_is_appended_verbatim() {
633        let completion = Completion {
634            content: vec![
635                ContentBlock::Reasoning {
636                    format: ReasoningFormat::Anthropic,
637                    text: "reasoning".into(),
638                    signature: Some("sig-xyz".into()),
639                    payload: None,
640                },
641                ContentBlock::Text {
642                    text: "answer".into(),
643                },
644            ],
645            usage: Usage::default(),
646            stop: StopReason::EndTurn,
647        };
648        let (mut s, events) = session_with(vec![Ok(completion)], Registry::new(), config());
649        let report = s.run_text("think").await;
650        assert_eq!(report.status, Status::Completed);
651        assert_eq!(report.final_message.as_deref(), Some("answer"));
652        // The emitted assistant message preserves the Thinking block + signature.
653        let has_thinking = dump(&events).iter().any(|e| match e {
654            Event::Message { message } if message.role == Role::Assistant => {
655                message.content.iter().any(|b| {
656                    matches!(
657                        b,
658                        ContentBlock::Reasoning { signature: Some(sig), .. } if sig == "sig-xyz"
659                    )
660                })
661            }
662            _ => false,
663        });
664        assert!(
665            has_thinking,
666            "thinking + signature must survive into history"
667        );
668    }
669
670    #[tokio::test]
671    async fn events_reconstruct_the_history() {
672        let (mut s, events) = session_with(
673            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
674            echo_registry(),
675            config(),
676        );
677        let _ = s.run_text("go").await;
678        let rebuilt: Conversation = reconstruct_conversation(&dump(&events));
679        // user + assistant(tool_use) + user(tool_result) + assistant(text).
680        let roles: Vec<Role> = rebuilt.messages.iter().map(|m| m.role).collect();
681        assert_eq!(
682            roles,
683            vec![Role::User, Role::Assistant, Role::User, Role::Assistant]
684        );
685    }
686
687    // ---- the approval seam (ADR-0017) ----
688
689    use std::sync::atomic::{AtomicUsize, Ordering};
690
691    /// A tool that counts its executions — proves a denied call never ran.
692    struct Counting(Arc<AtomicUsize>);
693    #[async_trait]
694    impl Tool for Counting {
695        type Args = Value;
696        type Output = EchoOut;
697        fn kind(&self) -> ToolKind {
698            ToolKind::Shell
699        }
700        fn description(&self) -> &str {
701            "counting"
702        }
703        async fn run(&self, _ctx: &ToolCtx, _args: Value) -> Result<EchoOut, ToolError> {
704            self.0.fetch_add(1, Ordering::SeqCst);
705            Ok(EchoOut {
706                echoed: "ran".into(),
707            })
708        }
709    }
710
711    type SeenKinds = Arc<Mutex<Vec<(String, Option<ToolKind>)>>>;
712
713    /// Denies tools whose name is in the list; allows everything else. Records
714    /// the `kind` seen on each request so tests can assert it is populated.
715    struct DenyNamed {
716        deny: Vec<&'static str>,
717        seen_kinds: SeenKinds,
718    }
719    #[async_trait]
720    impl Approver for DenyNamed {
721        async fn decide(&self, request: &ApprovalRequest<'_>) -> Decision {
722            self.seen_kinds
723                .lock()
724                .unwrap()
725                .push((request.tool_name.to_owned(), request.kind));
726            if self.deny.contains(&request.tool_name) {
727                Decision::Deny {
728                    reason: format!("{} is not allowed here", request.tool_name),
729                }
730            } else {
731                Decision::Allow
732            }
733        }
734    }
735
736    fn approvals(events: &Arc<Mutex<Vec<Event>>>) -> Vec<(String, String, String)> {
737        dump(events)
738            .iter()
739            .filter_map(|e| match e {
740                Event::Approval {
741                    tool_use_id,
742                    tool_name,
743                    decision,
744                    ..
745                } => Some((tool_use_id.clone(), tool_name.clone(), decision.clone())),
746                _ => None,
747            })
748            .collect()
749    }
750
751    #[tokio::test]
752    async fn deny_is_a_soft_paired_error_and_the_run_continues() {
753        let ran = Arc::new(AtomicUsize::new(0));
754        let mut reg = Registry::new();
755        reg.register("counting", Counting(Arc::clone(&ran)));
756        let (s, events) = session_with(
757            vec![Ok(tool_turn("c1", "counting")), Ok(text_turn("done"))],
758            reg,
759            config(),
760        );
761        let seen = Arc::new(Mutex::new(Vec::new()));
762        let mut s = s.with_approver(Arc::new(DenyNamed {
763            deny: vec!["counting"],
764            seen_kinds: Arc::clone(&seen),
765        }));
766        let report = s.run_text("go").await;
767
768        // Soft: the run continued to Completed; the tool never executed.
769        assert_eq!(report.status, Status::Completed);
770        assert_eq!(ran.load(Ordering::SeqCst), 0, "denied tool must not run");
771
772        // The record: ok=false, denial_reason set (and only here), no output.
773        assert_eq!(report.tool_calls.len(), 1);
774        let record = &report.tool_calls[0];
775        assert!(!record.ok);
776        assert_eq!(
777            record.denial_reason.as_deref(),
778            Some("counting is not allowed here")
779        );
780        assert_eq!(record.kind, "shell", "kind still recorded on denial");
781
782        // The transcript: a paired is_error result carrying the reason.
783        let denied_result = dump(&events).iter().any(|e| match e {
784            Event::Message { message } => message.content.iter().any(|b| {
785                matches!(
786                    b,
787                    ContentBlock::ToolResult { tool_use_id, is_error: true, content, .. }
788                        if tool_use_id == "c1"
789                            && content.iter().any(|c| matches!(
790                                c,
791                                locode_protocol::ResultChunk::Text { text }
792                                    if text == "tool call denied: counting is not allowed here"
793                            ))
794                )
795            }),
796            _ => false,
797        });
798        assert!(denied_result, "the model sees the denial reason, paired");
799
800        // The trace: a deny Approval event for c1.
801        assert_eq!(
802            approvals(&events),
803            vec![("c1".into(), "counting".into(), "deny".into())]
804        );
805    }
806
807    #[tokio::test]
808    async fn deny_then_allow_within_one_batch_keeps_order_and_pairing() {
809        let ran = Arc::new(AtomicUsize::new(0));
810        let mut reg = Registry::new();
811        reg.register("blocked", Counting(Arc::clone(&ran)));
812        reg.register("echo", Echo);
813        let batch = Completion {
814            content: vec![
815                ContentBlock::ToolUse {
816                    id: "c1".into(),
817                    name: "blocked".into(),
818                    input: json!({}),
819                },
820                ContentBlock::ToolUse {
821                    id: "c2".into(),
822                    name: "echo".into(),
823                    input: json!({}),
824                },
825            ],
826            usage: Usage::default(),
827            stop: StopReason::ToolUse,
828        };
829        let (s, events) = session_with(vec![Ok(batch), Ok(text_turn("done"))], reg, config());
830        let mut s = s.with_approver(Arc::new(DenyNamed {
831            deny: vec!["blocked"],
832            seen_kinds: Arc::new(Mutex::new(Vec::new())),
833        }));
834        let report = s.run_text("go").await;
835        assert_eq!(report.status, Status::Completed);
836        assert_eq!(ran.load(Ordering::SeqCst), 0);
837
838        // Both calls answered, in call order, denied first.
839        let pairs: Vec<(String, bool)> = dump(&events)
840            .iter()
841            .filter_map(|e| match e {
842                Event::Message { message } if message.role == Role::User => Some(&message.content),
843                _ => None,
844            })
845            .flatten()
846            .filter_map(|b| match b {
847                ContentBlock::ToolResult {
848                    tool_use_id,
849                    is_error,
850                    ..
851                } => Some((tool_use_id.clone(), *is_error)),
852                _ => None,
853            })
854            .collect();
855        assert_eq!(pairs, vec![("c1".into(), true), ("c2".into(), false)]);
856
857        // Records: denied (with reason) then executed (without).
858        assert_eq!(report.tool_calls.len(), 2);
859        assert!(report.tool_calls[0].denial_reason.is_some());
860        assert_eq!(report.tool_calls[0].kind, "shell");
861        assert!(report.tool_calls[1].ok);
862        assert_eq!(report.tool_calls[1].denial_reason, None);
863
864        // Approval trace: deny then allow, in order.
865        assert_eq!(
866            approvals(&events),
867            vec![
868                ("c1".into(), "blocked".into(), "deny".into()),
869                ("c2".into(), "echo".into(), "allow".into()),
870            ]
871        );
872    }
873
874    #[tokio::test]
875    async fn approval_request_carries_the_registry_kind() {
876        let seen = Arc::new(Mutex::new(Vec::new()));
877        let (s, _e) = session_with(
878            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
879            echo_registry(),
880            config(),
881        );
882        let mut s = s.with_approver(Arc::new(DenyNamed {
883            deny: vec![],
884            seen_kinds: Arc::clone(&seen),
885        }));
886        let _ = s.run_text("go").await;
887        let seen = seen.lock().unwrap();
888        assert_eq!(seen.len(), 1);
889        assert_eq!(seen[0].0, "echo");
890        assert_eq!(
891            seen[0].1,
892            Some(ToolKind::Shell),
893            "kind resolves from the registry pre-dispatch"
894        );
895    }
896
897    /// An approver that suspends on a oneshot until an external task resolves
898    /// it — the exact shape of a TUI prompt. Proves the engine awaits the
899    /// decision without deadlocking the run.
900    #[tokio::test]
901    async fn async_approver_suspends_the_call_until_resolved() {
902        struct OneshotApprover(Mutex<Option<tokio::sync::oneshot::Receiver<Decision>>>);
903        #[async_trait]
904        impl Approver for OneshotApprover {
905            async fn decide(&self, _request: &ApprovalRequest<'_>) -> Decision {
906                let rx = self.0.lock().unwrap().take().expect("one decision");
907                rx.await.expect("decider dropped")
908            }
909        }
910
911        let (tx, rx) = tokio::sync::oneshot::channel();
912        let (s, _e) = session_with(
913            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
914            echo_registry(),
915            config(),
916        );
917        let mut s = s.with_approver(Arc::new(OneshotApprover(Mutex::new(Some(rx)))));
918
919        // Resolve the prompt from "the UI" after the run has started.
920        let ui = tokio::spawn(async move {
921            tokio::task::yield_now().await;
922            let _ = tx.send(Decision::Allow);
923        });
924        let report = s.run_text("go").await;
925        ui.await.expect("ui task");
926        assert_eq!(report.status, Status::Completed);
927        assert_eq!(report.tool_calls.len(), 1);
928        assert!(report.tool_calls[0].ok);
929    }
930
931    #[tokio::test]
932    async fn allowed_calls_emit_approval_events_by_default() {
933        // The default AllowAll approver still journals every resolution.
934        let (mut s, events) = session_with(
935            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
936            echo_registry(),
937            config(),
938        );
939        let report = s.run_text("go").await;
940        assert_eq!(report.status, Status::Completed);
941        assert_eq!(
942            approvals(&events),
943            vec![("c1".into(), "echo".into(), "allow".into())]
944        );
945        // And denial_reason is absent on ordinary success records.
946        assert_eq!(report.tool_calls[0].denial_reason, None);
947    }
948
949    // ---- cancellation (ADR-0018) ----
950
951    /// A provider that streams part of a reply and *then* fails retryably — the
952    /// shape a lossy stream has (ADR-0007 amendment 2026-07-27). The first
953    /// attempt half-streams, the second succeeds.
954    struct HalfStreamsThenFails {
955        attempts: std::sync::atomic::AtomicU32,
956    }
957    #[async_trait]
958    impl Provider for HalfStreamsThenFails {
959        #[allow(clippy::unnecessary_literal_bound)]
960        fn api_schema(&self) -> &str {
961            "mock"
962        }
963        async fn complete(
964            &self,
965            _request: &ConversationRequest,
966        ) -> Result<Completion, ProviderError> {
967            unreachable!("this test runs streaming")
968        }
969        async fn stream(
970            &self,
971            _request: &ConversationRequest,
972            on_delta: &mut (dyn FnMut(locode_provider::CompletionDelta) + Send),
973        ) -> Result<Completion, ProviderError> {
974            let n = self
975                .attempts
976                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
977            if n == 0 {
978                on_delta(locode_provider::CompletionDelta::Text("Hel".into()));
979                on_delta(locode_provider::CompletionDelta::Text("lo wor".into()));
980                return Err(ProviderError::Transport("lossy stream".into()));
981            }
982            on_delta(locode_provider::CompletionDelta::Text("Hello world".into()));
983            Ok(Completion {
984                content: vec![ContentBlock::Text {
985                    text: "Hello world".into(),
986                }],
987                usage: Usage::default(),
988                stop: locode_provider::StopReason::EndTurn,
989            })
990        }
991    }
992
993    /// A stream that dies part-way is resampled from the start, so the deltas it
994    /// already emitted are void. The engine must say so — otherwise a consumer
995    /// buffering them renders the reply twice. Emitted only when something was
996    /// actually streamed: a failure before the first delta has nothing to annul.
997    #[tokio::test]
998    async fn a_partial_stream_that_resamples_annuls_its_deltas() {
999        let mut cfg = config();
1000        cfg.streaming = true;
1001        let provider = std::sync::Arc::new(HalfStreamsThenFails {
1002            attempts: std::sync::atomic::AtomicU32::new(0),
1003        });
1004        let events = Arc::new(Mutex::new(Vec::new()));
1005        let sink_events = Arc::clone(&events);
1006        let sink = Box::new(FnSink(move |event| {
1007            sink_events.lock().unwrap().push(event);
1008        }));
1009        let mut session = Session::new(provider, Registry::new(), vec![], cfg, sink);
1010        let report = session.run_text("go").await;
1011        assert_eq!(report.status, Status::Completed, "the retry succeeded");
1012
1013        let evs = dump(&events);
1014        let resets = evs
1015            .iter()
1016            .filter(|e| matches!(e, Event::MessageDeltaReset { .. }))
1017            .count();
1018        assert_eq!(resets, 1, "exactly one annulment, for the failed attempt");
1019
1020        // Order matters: the annulment must precede the retry's deltas, or a
1021        // consumer clears the buffer *after* refilling it.
1022        let reset_at = evs
1023            .iter()
1024            .position(|e| matches!(e, Event::MessageDeltaReset { .. }))
1025            .expect("reset emitted");
1026        let last_delta = evs
1027            .iter()
1028            .rposition(|e| matches!(e, Event::MessageDelta { .. }))
1029            .expect("the retry streamed");
1030        assert!(
1031            reset_at < last_delta,
1032            "reset must come before the re-stream"
1033        );
1034    }
1035
1036    /// A provider whose sample never returns on its own — cancellation is the
1037    /// only way out (models a long in-flight request).
1038    struct HangingProvider;
1039    #[async_trait]
1040    impl Provider for HangingProvider {
1041        #[allow(clippy::unnecessary_literal_bound)]
1042        fn api_schema(&self) -> &str {
1043            "mock"
1044        }
1045        async fn complete(
1046            &self,
1047            _request: &ConversationRequest,
1048        ) -> Result<Completion, ProviderError> {
1049            tokio::time::sleep(Duration::from_hours(1)).await;
1050            Err(ProviderError::Transport("unreachable".into()))
1051        }
1052    }
1053
1054    /// A tool that parks on its ctx cancel token and returns cleanly once it
1055    /// fires — the cooperative-cancel shape the host implements for real.
1056    struct WaitsForCancel;
1057    #[async_trait]
1058    impl Tool for WaitsForCancel {
1059        type Args = Value;
1060        type Output = EchoOut;
1061        fn kind(&self) -> ToolKind {
1062            ToolKind::Shell
1063        }
1064        fn description(&self) -> &str {
1065            "waits"
1066        }
1067        async fn run(&self, ctx: &ToolCtx, _args: Value) -> Result<EchoOut, ToolError> {
1068            ctx.cancel.cancelled().await;
1069            Ok(EchoOut {
1070                echoed: "stopped cooperatively".into(),
1071            })
1072        }
1073    }
1074
1075    #[tokio::test]
1076    async fn cancel_mid_sample_yields_cancelled_report() {
1077        let events = Arc::new(Mutex::new(Vec::new()));
1078        let sink_events = Arc::clone(&events);
1079        let sink = Box::new(FnSink(move |event| {
1080            sink_events.lock().unwrap().push(event);
1081        }));
1082        let mut s = Session::new(
1083            Arc::new(HangingProvider),
1084            Registry::new(),
1085            vec![],
1086            config(),
1087            sink,
1088        );
1089        let handle = s.cancel_handle();
1090        let canceller = tokio::spawn(async move {
1091            tokio::time::sleep(Duration::from_millis(20)).await;
1092            handle.cancel();
1093            handle.cancel(); // idempotent double-cancel
1094        });
1095        let report = s.run_text("go").await;
1096        canceller.await.expect("canceller");
1097
1098        assert_eq!(report.status, Status::Cancelled);
1099        assert_eq!(report.error, None, "cancelled is a stop, not a fault");
1100        assert_eq!(report.final_message, None, "no assistant text this run");
1101        assert_eq!(report.turns, 0, "no completion was accepted");
1102        // No assistant message was appended: history is user-prompt only.
1103        let roles: Vec<Role> = s.history().iter().map(|m| m.role).collect();
1104        assert_eq!(roles, vec![Role::User]);
1105        // The stream still terminates in a Result carrying the same report.
1106        let evs = dump(&events);
1107        assert!(
1108            matches!(evs.last(), Some(Event::Result { report }) if report.status == Status::Cancelled)
1109        );
1110    }
1111
1112    #[tokio::test]
1113    async fn cancel_mid_batch_pairs_the_rest_synthetically() {
1114        // One turn asks for TWO tools: a cooperative waiter, then echo. The
1115        // cancel fires while the waiter runs → its own result is real; echo is
1116        // never run (no approval consult, no record) but still paired.
1117        let mut reg = Registry::new();
1118        reg.register("waits", WaitsForCancel);
1119        reg.register("echo", Echo);
1120        let batch = Completion {
1121            content: vec![
1122                ContentBlock::ToolUse {
1123                    id: "c_wait".into(),
1124                    name: "waits".into(),
1125                    input: json!({}),
1126                },
1127                ContentBlock::ToolUse {
1128                    id: "c_echo".into(),
1129                    name: "echo".into(),
1130                    input: json!({}),
1131                },
1132            ],
1133            usage: Usage::default(),
1134            stop: StopReason::ToolUse,
1135        };
1136        let (s, events) = session_with(vec![Ok(batch)], reg, config());
1137        let mut s = s; // provider script has ONE turn: cancel must end the run
1138        let handle = s.cancel_handle();
1139        let canceller = tokio::spawn(async move {
1140            tokio::time::sleep(Duration::from_millis(20)).await;
1141            handle.cancel();
1142        });
1143        let report = s.run_text("go").await;
1144        canceller.await.expect("canceller");
1145
1146        assert_eq!(report.status, Status::Cancelled);
1147        // The waiter executed (cooperatively) and is the only record; no
1148        // cancellation synthetic ever carries denial_reason.
1149        assert_eq!(report.tool_calls.len(), 1);
1150        assert_eq!(report.tool_calls[0].id, "c_wait");
1151        assert!(report.tool_calls[0].ok);
1152        assert_eq!(report.tool_calls[0].denial_reason, None);
1153
1154        // Both tool_use ids are answered: real result + cancellation synthetic.
1155        let pairs: Vec<(String, bool)> = dump(&events)
1156            .iter()
1157            .filter_map(|e| match e {
1158                Event::Message { message } if message.role == Role::User => Some(&message.content),
1159                _ => None,
1160            })
1161            .flatten()
1162            .filter_map(|b| match b {
1163                ContentBlock::ToolResult {
1164                    tool_use_id,
1165                    is_error,
1166                    ..
1167                } => Some((tool_use_id.clone(), *is_error)),
1168                _ => None,
1169            })
1170            .collect();
1171        assert_eq!(
1172            pairs,
1173            vec![("c_wait".into(), false), ("c_echo".into(), true)]
1174        );
1175        // Only the executed call was consulted for approval.
1176        assert_eq!(
1177            approvals(&events),
1178            vec![("c_wait".into(), "waits".into(), "allow".into())]
1179        );
1180    }
1181
1182    /// The token is per-run (ADR-0018 Decision 1): a cancelled run 1 must not
1183    /// poison run 2, and run 2 continues the same conversation (with ADR-0016).
1184    #[tokio::test]
1185    async fn cancelled_session_continues_on_the_next_run_with_a_fresh_token() {
1186        let mut reg = Registry::new();
1187        reg.register("waits", WaitsForCancel);
1188        let (s, _e) = session_with(
1189            vec![Ok(tool_turn("c1", "waits")), Ok(text_turn("second run"))],
1190            reg,
1191            config(),
1192        );
1193        let mut s = s;
1194        let handle1 = s.cancel_handle();
1195        let canceller = tokio::spawn(async move {
1196            tokio::time::sleep(Duration::from_millis(20)).await;
1197            handle1.cancel();
1198        });
1199        let r1 = s.run_text("q1").await;
1200        canceller.await.expect("canceller");
1201        assert_eq!(r1.status, Status::Cancelled);
1202
1203        // The retired handle stays cancelled, but the session got a fresh
1204        // token at run end — run 2 must not see the old cancel.
1205        assert!(!s.cancel_handle().is_cancelled());
1206        let r2 = s.run_text("q2").await;
1207        assert_eq!(r2.status, Status::Completed);
1208        assert_eq!(r2.final_message.as_deref(), Some("second run"));
1209        // Continuity intact: q1's turns are still in the history.
1210        assert!(s.history().len() >= 4, "history: {:?}", s.history().len());
1211    }
1212
1213    // ---- session continuity (ADR-0016) ----
1214
1215    /// A scripted provider that also records each request's message array, so a
1216    /// test can assert what the model actually saw on a follow-up run.
1217    struct CapturingProvider {
1218        inner: MockProvider,
1219        requests: Arc<Mutex<Vec<Vec<Message>>>>,
1220    }
1221    #[async_trait]
1222    impl Provider for CapturingProvider {
1223        #[allow(clippy::unnecessary_literal_bound)]
1224        fn api_schema(&self) -> &str {
1225            "mock"
1226        }
1227        async fn complete(
1228            &self,
1229            request: &ConversationRequest,
1230        ) -> Result<Completion, ProviderError> {
1231            self.requests.lock().unwrap().push(request.messages.clone());
1232            self.inner.complete(request).await
1233        }
1234    }
1235
1236    /// Like `session_with`, but the provider records every request's messages.
1237    #[allow(clippy::type_complexity)]
1238    fn capturing_session_with(
1239        script: Vec<Result<Completion, ProviderError>>,
1240        registry: Registry,
1241    ) -> (
1242        Session,
1243        Arc<Mutex<Vec<Vec<Message>>>>,
1244        Arc<Mutex<Vec<Event>>>,
1245    ) {
1246        let requests = Arc::new(Mutex::new(Vec::new()));
1247        let events = Arc::new(Mutex::new(Vec::new()));
1248        let sink_events = Arc::clone(&events);
1249        let sink = Box::new(FnSink(move |event| {
1250            sink_events.lock().unwrap().push(event);
1251        }));
1252        let provider = Arc::new(CapturingProvider {
1253            inner: MockProvider::with_results(script),
1254            requests: Arc::clone(&requests),
1255        });
1256        let session = Session::new(provider, registry, vec![], config(), sink);
1257        (session, requests, events)
1258    }
1259
1260    fn user_text(message: &Message) -> Option<&str> {
1261        match (message.role, message.content.as_slice()) {
1262            (Role::User, [ContentBlock::Text { text }]) => Some(text.as_str()),
1263            _ => None,
1264        }
1265    }
1266
1267    #[tokio::test]
1268    async fn second_run_continues_the_conversation() {
1269        let (mut s, requests, _e) = capturing_session_with(
1270            vec![
1271                Ok(text_turn("first answer")),
1272                Ok(text_turn("second answer")),
1273            ],
1274            Registry::new(),
1275        );
1276        let r1 = s.run_text("q1").await;
1277        let r2 = s.run_text("q2").await;
1278        assert_eq!(r1.status, Status::Completed);
1279        assert_eq!(r2.status, Status::Completed);
1280        assert_eq!(r2.final_message.as_deref(), Some("second answer"));
1281
1282        // Run 2's request contains run 1's full exchange, then the new prompt.
1283        let reqs = requests.lock().unwrap();
1284        assert_eq!(reqs.len(), 2);
1285        let run2 = &reqs[1];
1286        assert_eq!(run2.len(), 3, "user q1, assistant, user q2: {run2:?}");
1287        assert_eq!(user_text(&run2[0]), Some("q1"));
1288        assert_eq!(run2[1].role, Role::Assistant);
1289        assert_eq!(user_text(&run2[2]), Some("q2"));
1290
1291        // The public accessor exposes the same transcript (empty test preamble).
1292        let roles: Vec<Role> = s.history().iter().map(|m| m.role).collect();
1293        assert_eq!(
1294            roles,
1295            vec![Role::User, Role::Assistant, Role::User, Role::Assistant]
1296        );
1297    }
1298
1299    // ---- project-instruction injection (ADR-0023, Task 30) ----
1300
1301    /// A capturing session over a custom config (for the injection path).
1302    fn capturing_with_cfg(
1303        script: Vec<Result<Completion, ProviderError>>,
1304        cfg: EngineConfig,
1305    ) -> (Session, Arc<Mutex<Vec<Vec<Message>>>>) {
1306        let requests = Arc::new(Mutex::new(Vec::new()));
1307        let provider = Arc::new(CapturingProvider {
1308            inner: MockProvider::with_results(script),
1309            requests: Arc::clone(&requests),
1310        });
1311        let session = Session::new(provider, Registry::new(), vec![], cfg, Box::new(NullSink));
1312        (session, requests)
1313    }
1314
1315    /// A config with project instructions **enabled**, rooted at `cwd`, global file off
1316    /// (never read the real `~/.locode` in tests).
1317    fn instr_config(cwd: std::path::PathBuf) -> EngineConfig {
1318        EngineConfig {
1319            cwd,
1320            instructions: locode_instructions::InstructionsConfig {
1321                global_file: false,
1322                ..Default::default()
1323            },
1324            ..config()
1325        }
1326    }
1327
1328    /// The injected `<system-reminder>` text within a request's messages, if present.
1329    fn reminder_text(msgs: &[Message]) -> Option<String> {
1330        msgs.iter()
1331            .find_map(|m| match (m.role, m.content.as_slice()) {
1332                (Role::User, [ContentBlock::Text { text }])
1333                    if text.starts_with("<system-reminder>") =>
1334                {
1335                    Some(text.clone())
1336                }
1337                _ => None,
1338            })
1339    }
1340
1341    fn reminder_count(msgs: &[Message]) -> usize {
1342        msgs.iter()
1343            .filter(|m| {
1344                matches!(
1345                    (m.role, m.content.as_slice()),
1346                    (Role::User, [ContentBlock::Text { text }]) if text.starts_with("<system-reminder>")
1347                )
1348            })
1349            .count()
1350    }
1351
1352    #[tokio::test]
1353    async fn project_instructions_injected_once_before_prompt() {
1354        let dir = tempfile::tempdir().unwrap();
1355        let root = std::fs::canonicalize(dir.path()).unwrap();
1356        std::fs::create_dir(root.join(".git")).unwrap();
1357        std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1358
1359        let (mut s, requests) = capturing_with_cfg(
1360            vec![Ok(text_turn("ok1")), Ok(text_turn("ok2"))],
1361            instr_config(root),
1362        );
1363        s.run_text("q1").await;
1364        s.run_text("q2").await;
1365
1366        let reqs = requests.lock().unwrap();
1367        // Run 1: injected, with the file content, positioned before the user prompt.
1368        let run1 = &reqs[0];
1369        let rem = reminder_text(run1).expect("instructions injected on run 1");
1370        assert!(rem.contains("## From:"), "labeled: {rem}");
1371        assert!(rem.contains("be terse"), "content present: {rem}");
1372        let rem_idx = run1
1373            .iter()
1374            .position(|m| reminder_text(std::slice::from_ref(m)).is_some());
1375        let q1_idx = run1.iter().position(|m| user_text(m) == Some("q1"));
1376        assert!(rem_idx < q1_idx, "reminder comes before the prompt");
1377
1378        // Run 2: NOT re-injected — still exactly the one from run 1 (once per session).
1379        assert_eq!(reminder_count(&reqs[1]), 1, "not re-injected on run 2");
1380    }
1381
1382    /// A session whose transcript is `replayed` — what resume does (the recovered
1383    /// history becomes the preamble).
1384    fn resumed_with_cfg(
1385        script: Vec<Result<Completion, ProviderError>>,
1386        cfg: EngineConfig,
1387        replayed: Vec<Message>,
1388    ) -> (Session, Arc<Mutex<Vec<Vec<Message>>>>) {
1389        let requests = Arc::new(Mutex::new(Vec::new()));
1390        let provider = Arc::new(CapturingProvider {
1391            inner: MockProvider::with_results(script),
1392            requests: Arc::clone(&requests),
1393        });
1394        let session = Session::new(provider, Registry::new(), replayed, cfg, Box::new(NullSink));
1395        (session, requests)
1396    }
1397
1398    /// ADR-0023's Refresh rule says instructions are "never double-injected on
1399    /// fork/resume". A remembered hash cannot deliver that — a resumed session starts
1400    /// with the field empty and re-sends instructions the replayed transcript already
1401    /// carries. The check reads the conversation instead.
1402    #[tokio::test]
1403    async fn resuming_does_not_re_inject_unchanged_instructions() {
1404        let dir = tempfile::tempdir().unwrap();
1405        let root = std::fs::canonicalize(dir.path()).unwrap();
1406        std::fs::create_dir(root.join(".git")).unwrap();
1407        std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1408
1409        let (mut first, requests) =
1410            capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root.clone()));
1411        first.run_text("q1").await;
1412        let replayed = requests.lock().unwrap()[0].clone();
1413        assert_eq!(reminder_count(&replayed), 1, "precondition: injected once");
1414
1415        let (mut resumed, resumed_requests) =
1416            resumed_with_cfg(vec![Ok(text_turn("ok2"))], instr_config(root), replayed);
1417        resumed.run_text("q2").await;
1418        let reqs = resumed_requests.lock().unwrap();
1419        assert_eq!(
1420            reminder_count(&reqs[0]),
1421            1,
1422            "still the one from before the resume, not a second copy: {:#?}",
1423            reqs[0]
1424        );
1425    }
1426
1427    /// …but an `AGENTS.md` edited between sessions *is* re-injected, with the replace
1428    /// banner that says the earlier copy no longer applies.
1429    #[tokio::test]
1430    async fn resuming_re_injects_instructions_that_changed_while_away() {
1431        let dir = tempfile::tempdir().unwrap();
1432        let root = std::fs::canonicalize(dir.path()).unwrap();
1433        std::fs::create_dir(root.join(".git")).unwrap();
1434        std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1435
1436        let (mut first, requests) =
1437            capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root.clone()));
1438        first.run_text("q1").await;
1439        let replayed = requests.lock().unwrap()[0].clone();
1440
1441        std::fs::write(root.join("AGENTS.md"), "be verbose").unwrap();
1442        let (mut resumed, resumed_requests) =
1443            resumed_with_cfg(vec![Ok(text_turn("ok2"))], instr_config(root), replayed);
1444        resumed.run_text("q2").await;
1445        let reqs = resumed_requests.lock().unwrap();
1446        assert_eq!(reminder_count(&reqs[0]), 2, "the new body joins the old");
1447        let latest = reqs[0]
1448            .iter()
1449            .rev()
1450            .find_map(|m| reminder_text(std::slice::from_ref(m)))
1451            .expect("a reminder");
1452        assert!(latest.contains("be verbose"), "{latest}");
1453        assert!(
1454            latest.contains("replace all previously provided"),
1455            "banner present: {latest}"
1456        );
1457    }
1458
1459    /// The other half of ADR-0023's Refresh rule: instructions dropped from the
1460    /// conversation come back. A remembered hash would keep saying "already sent".
1461    #[tokio::test]
1462    async fn instructions_dropped_from_the_transcript_are_re_injected() {
1463        let dir = tempfile::tempdir().unwrap();
1464        let root = std::fs::canonicalize(dir.path()).unwrap();
1465        std::fs::create_dir(root.join(".git")).unwrap();
1466        std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1467
1468        let (mut first, requests) =
1469            capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root.clone()));
1470        first.run_text("q1").await;
1471        // Compaction: keep the conversation, drop the reminder.
1472        let compacted: Vec<Message> = requests.lock().unwrap()[0]
1473            .iter()
1474            .filter(|m| reminder_text(std::slice::from_ref(m)).is_none())
1475            .cloned()
1476            .collect();
1477
1478        let (mut after, after_requests) =
1479            resumed_with_cfg(vec![Ok(text_turn("ok2"))], instr_config(root), compacted);
1480        after.run_text("q2").await;
1481        assert_eq!(
1482            reminder_count(&after_requests.lock().unwrap()[0]),
1483            1,
1484            "re-injected after being compacted away"
1485        );
1486    }
1487
1488    /// A skills config rooted at `cwd` that never reads the real `~/.locode`
1489    /// (`discover` resolves the home root itself, so the temp repo is the only source
1490    /// as long as no skill exists in the developer's home — the project root is what
1491    /// this asserts on).
1492    fn skills_config(cwd: std::path::PathBuf) -> EngineConfig {
1493        EngineConfig {
1494            cwd: cwd.clone(),
1495            skills: locode_skills::SkillsConfig::enabled(),
1496            ..instr_config(cwd)
1497        }
1498    }
1499
1500    fn write_skill(root: &std::path::Path, name: &str, description: &str) {
1501        let dir = root.join(".agents/skills").join(name);
1502        std::fs::create_dir_all(&dir).unwrap();
1503        std::fs::write(
1504            dir.join("SKILL.md"),
1505            format!("---\nname: {name}\ndescription: {description}\n---\n# {name}\n"),
1506        )
1507        .unwrap();
1508    }
1509
1510    /// Injected once, before the prompt; and a second turn with no change sends nothing
1511    /// new — the whole-body comparison is what makes the steady state quiet.
1512    #[tokio::test]
1513    async fn skills_listing_injected_once_then_quiet() {
1514        let dir = tempfile::tempdir().unwrap();
1515        let root = std::fs::canonicalize(dir.path()).unwrap();
1516        std::fs::create_dir(root.join(".git")).unwrap();
1517        write_skill(&root, "commit", "Make a commit");
1518
1519        let (mut s, requests) = capturing_with_cfg(
1520            vec![Ok(text_turn("a")), Ok(text_turn("b"))],
1521            skills_config(root),
1522        );
1523        s.run_text("q1").await;
1524        s.run_text("q2").await;
1525
1526        let reqs = requests.lock().unwrap();
1527        let listing = reqs[0]
1528            .iter()
1529            .filter_map(|m| reminder_text(std::slice::from_ref(m)))
1530            .find(|t| t.contains("skills are available"))
1531            .expect("listing injected");
1532        assert!(listing.contains(r#"<skill name="commit""#), "{listing}");
1533        assert!(listing.contains("Make a commit"), "{listing}");
1534        assert!(
1535            listing.contains("SKILL.md\">"),
1536            "the path attribute: {listing}"
1537        );
1538
1539        let count = |msgs: &[Message]| {
1540            msgs.iter()
1541                .filter(|m| {
1542                    reminder_text(std::slice::from_ref(m))
1543                        .is_some_and(|t| t.contains("skills are available"))
1544                })
1545                .count()
1546        };
1547        assert_eq!(count(&reqs[1]), 1, "unchanged ⇒ not re-sent");
1548    }
1549
1550    /// Adding a skill re-sends the **whole** listing, not just the new entry — the
1551    /// defect the per-skill delta in Claude Code and grok produces (ADR-0025 §3.1).
1552    ///
1553    /// Also pins the timing consequence of §3.2: the scan runs *after* a run finishes,
1554    /// so a skill created while the user is typing is picked up by the **next** run's
1555    /// post-run scan and injected on the turn after that. Writing it during a run — the
1556    /// case the design optimizes for — costs no extra turn.
1557    #[tokio::test]
1558    async fn adding_a_skill_re_sends_the_entire_listing() {
1559        let dir = tempfile::tempdir().unwrap();
1560        let root = std::fs::canonicalize(dir.path()).unwrap();
1561        std::fs::create_dir(root.join(".git")).unwrap();
1562        write_skill(&root, "commit", "Make a commit");
1563
1564        let (mut s, requests) = capturing_with_cfg(
1565            vec![Ok(text_turn("a")), Ok(text_turn("b")), Ok(text_turn("c"))],
1566            skills_config(root.clone()),
1567        );
1568        s.run_text("q1").await;
1569        write_skill(&root, "review", "Review a diff"); // after run 1's post-run scan
1570        s.run_text("q2").await; // still the cached body; run 2's scan picks it up
1571        s.run_text("q3").await;
1572
1573        let reqs = requests.lock().unwrap();
1574        let listing = |msgs: &[Message]| {
1575            msgs.iter()
1576                .filter_map(|m| reminder_text(std::slice::from_ref(m)))
1577                .rfind(|t| t.contains("skills are available"))
1578        };
1579        assert!(
1580            !listing(&reqs[1]).unwrap().contains(r#"name="review""#),
1581            "not yet — the scan that would see it runs at the end of this run"
1582        );
1583        let third = listing(&reqs[2]).expect("re-sent");
1584        assert!(
1585            third.contains(r#"name="commit""#),
1586            "old skill included: {third}"
1587        );
1588        assert!(
1589            third.contains(r#"name="review""#),
1590            "new skill included: {third}"
1591        );
1592    }
1593
1594    /// Removing the last skill says so, rather than going quiet and leaving a stale
1595    /// instruction standing (ADR-0025 §3.1 — codex's behavior, not the other two's).
1596    #[tokio::test]
1597    async fn removing_the_last_skill_announces_it() {
1598        let dir = tempfile::tempdir().unwrap();
1599        let root = std::fs::canonicalize(dir.path()).unwrap();
1600        std::fs::create_dir(root.join(".git")).unwrap();
1601        write_skill(&root, "commit", "Make a commit");
1602
1603        let (mut s, requests) = capturing_with_cfg(
1604            vec![Ok(text_turn("a")), Ok(text_turn("b")), Ok(text_turn("c"))],
1605            skills_config(root.clone()),
1606        );
1607        s.run_text("q1").await;
1608        std::fs::remove_dir_all(root.join(".agents/skills/commit")).unwrap();
1609        s.run_text("q2").await; // run 2's post-run scan observes the removal
1610        s.run_text("q3").await;
1611
1612        let reqs = requests.lock().unwrap();
1613        let last = reqs[2]
1614            .iter()
1615            .filter_map(|m| reminder_text(std::slice::from_ref(m)))
1616            .next_back()
1617            .expect("a reminder");
1618        assert!(last.contains("No skills are currently available"), "{last}");
1619    }
1620
1621    /// A project with no skills at all must not open with a pointless denial.
1622    #[tokio::test]
1623    async fn no_skills_ever_means_no_message_at_all() {
1624        let dir = tempfile::tempdir().unwrap();
1625        let root = std::fs::canonicalize(dir.path()).unwrap();
1626        std::fs::create_dir(root.join(".git")).unwrap();
1627
1628        let (mut s, requests) = capturing_with_cfg(vec![Ok(text_turn("a"))], skills_config(root));
1629        s.run_text("q1").await;
1630
1631        let reqs = requests.lock().unwrap();
1632        assert!(
1633            !reqs[0]
1634                .iter()
1635                .any(|m| reminder_text(std::slice::from_ref(m))
1636                    .is_some_and(|t| t.contains("skills"))),
1637            "silence, not a denial"
1638        );
1639    }
1640
1641    #[tokio::test]
1642    async fn project_instructions_absent_when_disabled() {
1643        let dir = tempfile::tempdir().unwrap();
1644        let root = std::fs::canonicalize(dir.path()).unwrap();
1645        std::fs::create_dir(root.join(".git")).unwrap();
1646        std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1647        let mut cfg = instr_config(root);
1648        cfg.instructions.enabled = false;
1649
1650        let (mut s, requests) = capturing_with_cfg(vec![Ok(text_turn("ok"))], cfg);
1651        s.run_text("q").await;
1652        assert!(reminder_text(&requests.lock().unwrap()[0]).is_none());
1653    }
1654
1655    #[tokio::test]
1656    async fn project_instructions_absent_when_no_agents_md() {
1657        let dir = tempfile::tempdir().unwrap();
1658        let root = std::fs::canonicalize(dir.path()).unwrap();
1659        // Empty tempdir — no `.git`, no `AGENTS.md` → nothing discovered.
1660        let (mut s, requests) = capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root));
1661        s.run_text("q").await;
1662        assert!(reminder_text(&requests.lock().unwrap()[0]).is_none());
1663    }
1664
1665    #[tokio::test]
1666    async fn project_instructions_replace_banner_on_edit() {
1667        let dir = tempfile::tempdir().unwrap();
1668        let root = std::fs::canonicalize(dir.path()).unwrap();
1669        std::fs::create_dir(root.join(".git")).unwrap();
1670        let agents = root.join("AGENTS.md");
1671        std::fs::write(&agents, "v1 rules").unwrap();
1672
1673        let (mut s, requests) = capturing_with_cfg(
1674            vec![Ok(text_turn("ok1")), Ok(text_turn("ok2"))],
1675            instr_config(root),
1676        );
1677        s.run_text("q1").await;
1678        std::fs::write(&agents, "v2 rules").unwrap(); // edit between runs
1679        s.run_text("q2").await;
1680
1681        let reqs = requests.lock().unwrap();
1682        let run2 = &reqs[1];
1683        let banner = run2
1684            .iter()
1685            .find_map(|m| match (m.role, m.content.as_slice()) {
1686                (Role::User, [ContentBlock::Text { text }])
1687                    if text.contains("replace all previously provided") =>
1688                {
1689                    Some(text.clone())
1690                }
1691                _ => None,
1692            })
1693            .expect("replace banner on edit");
1694        assert!(banner.contains("v2 rules"), "new content: {banner}");
1695        assert!(!banner.contains("v1 rules"), "not the old content");
1696        // Both the original (v1) and the replacement (v2) reminders are in the transcript.
1697        assert_eq!(reminder_count(run2), 2);
1698    }
1699
1700    #[tokio::test]
1701    async fn project_instructions_removal_banner_on_delete() {
1702        let dir = tempfile::tempdir().unwrap();
1703        let root = std::fs::canonicalize(dir.path()).unwrap();
1704        std::fs::create_dir(root.join(".git")).unwrap();
1705        let agents = root.join("AGENTS.md");
1706        std::fs::write(&agents, "rules").unwrap();
1707
1708        let (mut s, requests) = capturing_with_cfg(
1709            vec![Ok(text_turn("ok1")), Ok(text_turn("ok2"))],
1710            instr_config(root),
1711        );
1712        s.run_text("q1").await;
1713        std::fs::remove_file(&agents).unwrap(); // delete between runs
1714        s.run_text("q2").await;
1715
1716        let reqs = requests.lock().unwrap();
1717        assert!(
1718            reqs[1].iter().any(|m| matches!(
1719                (m.role, m.content.as_slice()),
1720                (Role::User, [ContentBlock::Text { text }]) if text.contains("no longer apply")
1721            )),
1722            "removal notice on delete"
1723        );
1724    }
1725
1726    #[tokio::test]
1727    async fn project_instructions_not_reinjected_when_unchanged() {
1728        let dir = tempfile::tempdir().unwrap();
1729        let root = std::fs::canonicalize(dir.path()).unwrap();
1730        std::fs::create_dir(root.join(".git")).unwrap();
1731        std::fs::write(root.join("AGENTS.md"), "stable").unwrap();
1732
1733        let (mut s, requests) = capturing_with_cfg(
1734            vec![
1735                Ok(text_turn("ok1")),
1736                Ok(text_turn("ok2")),
1737                Ok(text_turn("ok3")),
1738            ],
1739            instr_config(root),
1740        );
1741        s.run_text("q1").await;
1742        s.run_text("q2").await;
1743        s.run_text("q3").await;
1744        // Exactly one injection across three unchanged turns.
1745        assert_eq!(reminder_count(&requests.lock().unwrap()[2]), 1);
1746    }
1747
1748    #[tokio::test]
1749    async fn init_emitted_once_across_runs_with_one_result_each() {
1750        let (mut s, events) = session_with(
1751            vec![Ok(text_turn("one")), Ok(text_turn("two"))],
1752            Registry::new(),
1753            config(),
1754        );
1755        let _ = s.run_text("q1").await;
1756        let _ = s.run_text("q2").await;
1757        let evs = dump(&events);
1758        let inits = evs
1759            .iter()
1760            .filter(|e| matches!(e, Event::Init { .. }))
1761            .count();
1762        let results = evs
1763            .iter()
1764            .filter(|e| matches!(e, Event::Result { .. }))
1765            .count();
1766        assert_eq!(inits, 1, "Init is once per session, not per run");
1767        assert_eq!(results, 2, "one Result per run");
1768        assert!(
1769            matches!(evs.first(), Some(Event::Init { .. })),
1770            "Init still opens the stream"
1771        );
1772    }
1773
1774    #[tokio::test]
1775    async fn report_counts_are_per_run_not_cumulative() {
1776        // Run 1: tool turn + text (2 turns, 1 tool call, 10/5 tokens).
1777        // Run 2: text only (1 turn, 0 tool calls, 20/7 tokens).
1778        let mut t1 = tool_turn("c1", "echo");
1779        t1.usage = Usage {
1780            input_tokens: 10,
1781            output_tokens: 5,
1782            ..Usage::default()
1783        };
1784        let t2 = text_turn("done one");
1785        let mut t3 = text_turn("done two");
1786        t3.usage = Usage {
1787            input_tokens: 20,
1788            output_tokens: 7,
1789            ..Usage::default()
1790        };
1791        let (mut s, _e) = session_with(vec![Ok(t1), Ok(t2), Ok(t3)], echo_registry(), config());
1792        let r1 = s.run_text("q1").await;
1793        let r2 = s.run_text("q2").await;
1794        assert_eq!(r1.turns, 2);
1795        assert_eq!(r1.tool_calls.len(), 1);
1796        assert_eq!(r2.turns, 1, "run 2 counts its own turns only");
1797        assert!(r2.tool_calls.is_empty());
1798        assert_eq!(r2.usage.input_tokens, 20, "usage is per-run");
1799        assert_eq!(r2.usage.output_tokens, 7);
1800    }
1801
1802    /// Golden: a two-run stream (`Init M+ Result M+ Result`) reconstructs the
1803    /// full cross-run conversation (ADR-0014 amendment 2026-07-21).
1804    #[tokio::test]
1805    async fn two_run_stream_reconstructs_the_full_conversation() {
1806        let (mut s, events) = session_with(
1807            vec![
1808                Ok(tool_turn("c1", "echo")),
1809                Ok(text_turn("done one")),
1810                Ok(text_turn("done two")),
1811            ],
1812            echo_registry(),
1813            config(),
1814        );
1815        let _ = s.run_text("q1").await;
1816        let _ = s.run_text("q2").await;
1817        let rebuilt: Conversation = reconstruct_conversation(&dump(&events));
1818        // Run 1: user, assistant(tool_use), user(tool_result), assistant(text);
1819        // run 2: user, assistant(text).
1820        let roles: Vec<Role> = rebuilt.messages.iter().map(|m| m.role).collect();
1821        assert_eq!(
1822            roles,
1823            vec![
1824                Role::User,
1825                Role::Assistant,
1826                Role::User,
1827                Role::Assistant,
1828                Role::User,
1829                Role::Assistant,
1830            ]
1831        );
1832        // And the reconstruction matches the session's own history exactly.
1833        assert_eq!(rebuilt.messages.as_slice(), s.history());
1834    }
1835
1836    /// Continuing after a `ModelError` run is allowed unconditionally
1837    /// (ADR-0016 Resolution): the history simply didn't advance.
1838    #[tokio::test]
1839    async fn continues_after_model_error() {
1840        let (mut s, requests, _e) = capturing_session_with(
1841            vec![Err(ProviderError::ContextOverflow), Ok(text_turn("ok now"))],
1842            Registry::new(),
1843        );
1844        let r1 = s.run_text("q1").await;
1845        let r2 = s.run_text("q2").await;
1846        assert_eq!(r1.status, Status::ModelError);
1847        assert_eq!(r2.status, Status::Completed);
1848        // Run 2's request: q1's user message survived; no phantom assistant turn.
1849        let reqs = requests.lock().unwrap();
1850        let run2 = &reqs[1];
1851        assert_eq!(run2.len(), 2, "user q1 + user q2: {run2:?}");
1852        assert_eq!(user_text(&run2[0]), Some("q1"));
1853        assert_eq!(user_text(&run2[1]), Some("q2"));
1854    }
1855
1856    /// Continuing after a fatal tool `Error` run: the transcript was fully
1857    /// paired before the break, so the next sample sees a valid history.
1858    #[tokio::test]
1859    async fn continues_after_fatal_tool_error_with_valid_pairing() {
1860        let mut reg = Registry::new();
1861        reg.register("boom", Boom);
1862        let (mut s, requests, _e) = capturing_session_with(
1863            vec![Ok(tool_turn("c1", "boom")), Ok(text_turn("recovered"))],
1864            reg,
1865        );
1866        let r1 = s.run_text("q1").await;
1867        let r2 = s.run_text("q2").await;
1868        assert_eq!(r1.status, Status::Error);
1869        assert_eq!(r2.status, Status::Completed);
1870
1871        // Run 2's request replays the failed run intact: the boom tool_use is
1872        // answered by its (is_error) tool_result.
1873        let reqs = requests.lock().unwrap();
1874        let run2 = &reqs[1];
1875        assert_eq!(run2.len(), 4, "q1, assistant, tool_result, q2: {run2:?}");
1876        assert!(
1877            run2[1]
1878                .content
1879                .iter()
1880                .any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "c1"))
1881        );
1882        assert!(run2[2].content.iter().any(|b| matches!(
1883            b,
1884            ContentBlock::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "c1"
1885        )));
1886        assert_eq!(user_text(&run2[3]), Some("q2"));
1887    }
1888
1889    #[tokio::test]
1890    async fn usage_is_summed_across_turns() {
1891        let mut first = tool_turn("c1", "echo");
1892        first.usage = Usage {
1893            input_tokens: 10,
1894            output_tokens: 5,
1895            ..Usage::default()
1896        };
1897        let mut second = text_turn("done");
1898        second.usage = Usage {
1899            input_tokens: 20,
1900            output_tokens: 7,
1901            ..Usage::default()
1902        };
1903        let (mut s, _e) = session_with(vec![Ok(first), Ok(second)], echo_registry(), config());
1904        let report = s.run_text("go").await;
1905        assert_eq!(report.usage.input_tokens, 30);
1906        assert_eq!(report.usage.output_tokens, 12);
1907    }
1908
1909    /// Switching the model swaps the provider and **announces** the change instead of
1910    /// rewriting the preamble.
1911    ///
1912    /// A pack's system prompt may name the model, and after a switch that line is
1913    /// stale — but rewriting the `System` message would desync the conversation from
1914    /// the trace, whose `Init` record already captured the original preamble. Appending
1915    /// is the same discipline project instructions and skills follow.
1916    #[tokio::test]
1917    async fn setting_the_model_announces_it_without_touching_the_preamble() {
1918        let preamble = vec![Message {
1919            role: Role::System,
1920            content: vec![ContentBlock::Text {
1921                text: "You are powered by the model old-1.".into(),
1922            }],
1923        }];
1924        let provider = Arc::new(MockProvider::with_results(vec![Ok(text_turn("ok"))]));
1925        let mut s = Session::new(
1926            provider.clone(),
1927            Registry::new(),
1928            preamble.clone(),
1929            config(),
1930            Box::new(NullSink),
1931        );
1932
1933        let notice = s.set_model(provider, "new-2");
1934        s.announce(notice);
1935
1936        assert_eq!(
1937            s.history()[0],
1938            preamble[0],
1939            "the preamble is untouched — the trace already recorded it"
1940        );
1941        let last = s.history().last().expect("announcement appended");
1942        assert_eq!(last.role, Role::User);
1943        let ContentBlock::Text { text } = &last.content[0] else {
1944            panic!("text block")
1945        };
1946        assert!(text.starts_with("<system-reminder>"), "{text}");
1947        assert!(text.contains("is now new-2"), "{text}");
1948        assert!(
1949            text.contains("out of date"),
1950            "corrects the stale line: {text}"
1951        );
1952    }
1953
1954    /// `context_usage` is the **final** turn's, not the sum.
1955    ///
1956    /// Every turn's request re-sends the whole conversation, so summing counts the same
1957    /// history once per turn — a number that only grows and says nothing about how full
1958    /// the context is. The last turn's request *is* the whole conversation.
1959    #[tokio::test]
1960    async fn context_usage_is_the_final_turn_not_the_sum() {
1961        let mut first = tool_turn("c1", "echo");
1962        first.usage = Usage {
1963            input_tokens: 10,
1964            output_tokens: 5,
1965            ..Usage::default()
1966        };
1967        let mut second = text_turn("done");
1968        second.usage = Usage {
1969            input_tokens: 20,
1970            output_tokens: 7,
1971            cache_read_tokens: Some(4),
1972            cache_creation_tokens: Some(3),
1973            ..Usage::default()
1974        };
1975        let (mut s, _e) = session_with(vec![Ok(first), Ok(second)], echo_registry(), config());
1976        let report = s.run_text("go").await;
1977
1978        assert_eq!(report.context_usage.input_tokens, 20, "the last turn only");
1979        assert_eq!(report.context_usage.output_tokens, 7);
1980        assert_eq!(
1981            report.context_usage.context_tokens(),
1982            20 + 4 + 3 + 7,
1983            "both cache counters are prompt tokens"
1984        );
1985        assert_eq!(report.usage.input_tokens, 30, "the sum is still the sum");
1986    }
1987}