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