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 config;
11mod run;
12mod session;
13mod sink;
14mod terminal;
15
16pub use config::EngineConfig;
17pub use session::Session;
18pub use sink::{EventSink, FnSink, NullSink};
19
20#[cfg(test)]
21mod tests {
22    // Test tools return `&'static str` literals from `description`; the trait ties it
23    // to `&self` so real tools can return a stored field.
24    #![allow(clippy::unnecessary_literal_bound)]
25
26    use super::*;
27    use async_trait::async_trait;
28    use locode_protocol::{
29        ContentBlock, Conversation, Event, ReasoningFormat, Role, Status, Usage,
30        reconstruct_conversation,
31    };
32    use locode_provider::{Completion, MockProvider, ProviderError, StopReason};
33    use locode_tools::{Registry, Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
34    use serde::Serialize;
35    use serde_json::{Value, json};
36    use std::sync::{Arc, Mutex};
37    use std::time::Duration;
38
39    // ---- trivial in-test tools ----
40
41    #[derive(Serialize)]
42    struct EchoOut {
43        echoed: String,
44    }
45    impl ToolOutput for EchoOut {
46        fn to_prompt_text(&self) -> String {
47            self.echoed.clone()
48        }
49    }
50
51    struct Echo;
52    #[async_trait]
53    impl Tool for Echo {
54        type Args = Value;
55        type Output = EchoOut;
56        fn kind(&self) -> ToolKind {
57            ToolKind::Shell
58        }
59        fn description(&self) -> &str {
60            "echo"
61        }
62        async fn run(&self, _ctx: &ToolCtx, args: Value) -> Result<EchoOut, ToolError> {
63            Ok(EchoOut {
64                echoed: args.to_string(),
65            })
66        }
67    }
68
69    struct Boom;
70    #[async_trait]
71    impl Tool for Boom {
72        type Args = Value;
73        type Output = EchoOut;
74        fn kind(&self) -> ToolKind {
75            ToolKind::Shell
76        }
77        fn description(&self) -> &str {
78            "boom"
79        }
80        async fn run(&self, _ctx: &ToolCtx, _args: Value) -> Result<EchoOut, ToolError> {
81            Err(ToolError::Fatal("boom aborted the turn".into()))
82        }
83    }
84
85    // ---- harness ----
86
87    fn text_turn(text: &str) -> Completion {
88        Completion {
89            content: vec![ContentBlock::Text { text: text.into() }],
90            usage: Usage::default(),
91            stop: StopReason::EndTurn,
92        }
93    }
94
95    fn tool_turn(id: &str, name: &str) -> Completion {
96        Completion {
97            content: vec![ContentBlock::ToolUse {
98                id: id.into(),
99                name: name.into(),
100                input: json!({}),
101            }],
102            usage: Usage::default(),
103            stop: StopReason::ToolUse,
104        }
105    }
106
107    fn config() -> EngineConfig {
108        EngineConfig {
109            session_id: "sess-1".into(),
110            harness: "grok".into(),
111            api_schema: "mock".into(),
112            model: "mock-1".into(),
113            max_turns: None,
114            resample_retries: 2,
115            resample_backoff: Duration::ZERO, // no real sleeps in tests
116            ..EngineConfig::default()
117        }
118    }
119
120    /// Build a session with a scripted provider + registry, collecting events.
121    fn session_with(
122        script: Vec<Result<Completion, ProviderError>>,
123        registry: Registry,
124        cfg: EngineConfig,
125    ) -> (Session, Arc<Mutex<Vec<Event>>>) {
126        let events = Arc::new(Mutex::new(Vec::new()));
127        let sink_events = Arc::clone(&events);
128        let sink = Box::new(FnSink(move |event| {
129            sink_events.lock().unwrap().push(event);
130        }));
131        let provider = Arc::new(MockProvider::with_results(script));
132        let session = Session::new(provider, registry, vec![], cfg, sink);
133        (session, events)
134    }
135
136    fn echo_registry() -> Registry {
137        let mut reg = Registry::new();
138        reg.register("echo", Echo);
139        reg
140    }
141
142    fn dump(events: &Arc<Mutex<Vec<Event>>>) -> Vec<Event> {
143        events.lock().unwrap().clone()
144    }
145
146    // ---- terminal-state matrix ----
147
148    #[tokio::test]
149    async fn completed_with_no_tools() {
150        let (mut s, events) =
151            session_with(vec![Ok(text_turn("all done"))], Registry::new(), config());
152        let report = s.run_text("hi").await;
153        assert_eq!(report.status, Status::Completed);
154        assert_eq!(report.final_message.as_deref(), Some("all done"));
155        assert_eq!(report.turns, 1);
156        assert!(report.tool_calls.is_empty());
157        assert_eq!(report.api_schema, "mock");
158        // Init, Message(user), Message(assistant), Result.
159        let evs = dump(&events);
160        assert!(matches!(evs.first(), Some(Event::Init { .. })));
161        assert!(matches!(evs.last(), Some(Event::Result { .. })));
162    }
163
164    #[tokio::test]
165    async fn tool_call_then_complete() {
166        let (mut s, _e) = session_with(
167            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
168            echo_registry(),
169            config(),
170        );
171        let report = s.run_text("go").await;
172        assert_eq!(report.status, Status::Completed);
173        assert_eq!(report.turns, 2);
174        assert_eq!(report.tool_calls.len(), 1);
175        assert!(report.tool_calls[0].ok);
176        assert_eq!(report.tool_calls[0].name, "echo");
177    }
178
179    #[tokio::test]
180    async fn hits_max_turns_after_dispatch() {
181        // Always asks for a tool → never completes; ceiling of 2.
182        let mut cfg = config();
183        cfg.max_turns = Some(2);
184        let (mut s, _e) = session_with(
185            vec![
186                Ok(tool_turn("c1", "echo")),
187                Ok(tool_turn("c2", "echo")),
188                Ok(tool_turn("c3", "echo")),
189            ],
190            echo_registry(),
191            cfg,
192        );
193        let report = s.run_text("go").await;
194        assert_eq!(report.status, Status::MaxTurns);
195        assert_eq!(report.turns, 2);
196        assert_eq!(report.tool_calls.len(), 2);
197    }
198
199    #[tokio::test]
200    async fn model_error_after_bounded_retry() {
201        // Retryable every time → 1 + resample_retries attempts, then ModelError.
202        let script = vec![
203            Err(ProviderError::Transport("reset".into())),
204            Err(ProviderError::Transport("reset".into())),
205            Err(ProviderError::Transport("reset".into())),
206        ];
207        let (mut s, events) = session_with(script, Registry::new(), config());
208        let report = s.run_text("go").await;
209        assert_eq!(report.status, Status::ModelError);
210        assert!(report.error.is_some());
211        assert_eq!(report.turns, 0);
212        // Two non-terminal Error retry notes emitted (resample_retries == 2).
213        let retries = dump(&events)
214            .iter()
215            .filter(|e| matches!(e, Event::Error { .. }))
216            .count();
217        assert_eq!(retries, 2);
218    }
219
220    #[tokio::test]
221    async fn model_error_non_retryable_is_immediate() {
222        let (mut s, events) = session_with(
223            vec![Err(ProviderError::ContextOverflow)],
224            Registry::new(),
225            config(),
226        );
227        let report = s.run_text("go").await;
228        assert_eq!(report.status, Status::ModelError);
229        let retries = dump(&events)
230            .iter()
231            .filter(|e| matches!(e, Event::Error { .. }))
232            .count();
233        assert_eq!(retries, 0, "a non-retryable error must not resample");
234    }
235
236    #[tokio::test]
237    async fn fatal_tool_error_ends_the_run() {
238        let mut reg = Registry::new();
239        reg.register("boom", Boom);
240        let (mut s, _e) = session_with(vec![Ok(tool_turn("c1", "boom"))], reg, config());
241        let report = s.run_text("go").await;
242        assert_eq!(report.status, Status::Error);
243        assert!(report.error.is_some());
244        // The boom call still produced a paired (is_error) record.
245        assert_eq!(report.tool_calls.len(), 1);
246        assert!(!report.tool_calls[0].ok);
247    }
248
249    /// An empty completion (no text, no tool calls — e.g. a reasoning-only
250    /// turn truncated by `max_output_tokens`) is resampled, not labeled
251    /// Completed (ADR-0005 amendment 2026-07-19; grok's `is_empty` rule).
252    #[tokio::test]
253    async fn empty_completion_resamples_then_succeeds() {
254        let empty = Completion {
255            content: vec![ContentBlock::Reasoning {
256                format: ReasoningFormat::Anthropic,
257                text: "thinking only".into(),
258                signature: Some("sig".into()),
259                payload: None,
260            }],
261            usage: Usage::default(),
262            stop: StopReason::MaxTokens,
263        };
264        let (mut session, _events) = session_with(
265            vec![Ok(empty), Ok(text_turn("recovered"))],
266            echo_registry(),
267            config(),
268        );
269        let report = session.run_text("go").await;
270        assert_eq!(report.status, Status::Completed);
271        assert_eq!(report.final_message.as_deref(), Some("recovered"));
272        assert_eq!(report.stop_reason.as_deref(), Some("end_turn"));
273    }
274
275    #[tokio::test]
276    async fn persistent_empty_completions_are_model_error() {
277        let empty = || Completion {
278            content: vec![],
279            usage: Usage::default(),
280            stop: StopReason::MaxTokens,
281        };
282        // resample_retries = 2 → initial + 2 resamples, all empty → ModelError.
283        let (mut session, _events) = session_with(
284            vec![Ok(empty()), Ok(empty()), Ok(empty())],
285            echo_registry(),
286            config(),
287        );
288        let report = session.run_text("go").await;
289        assert_eq!(report.status, Status::ModelError);
290        assert!(
291            report
292                .error
293                .as_deref()
294                .unwrap_or("")
295                .contains("empty completion"),
296            "error names the cause: {:?}",
297            report.error
298        );
299        assert_eq!(report.stop_reason, None, "no completion was accepted");
300    }
301
302    // ---- transcript hygiene ----
303
304    #[tokio::test]
305    async fn mid_batch_abort_synthesizes_results() {
306        // One assistant turn asks for TWO tools: boom (Fatal) then echo. echo must
307        // not run, yet both tool_use ids must be answered in the transcript.
308        let mut reg = Registry::new();
309        reg.register("boom", Boom);
310        reg.register("echo", Echo);
311        let completion = Completion {
312            content: vec![
313                ContentBlock::ToolUse {
314                    id: "c_boom".into(),
315                    name: "boom".into(),
316                    input: json!({}),
317                },
318                ContentBlock::ToolUse {
319                    id: "c_echo".into(),
320                    name: "echo".into(),
321                    input: json!({}),
322                },
323            ],
324            usage: Usage::default(),
325            stop: StopReason::ToolUse,
326        };
327        let (mut s, events) = session_with(vec![Ok(completion)], reg, config());
328        let report = s.run_text("go").await;
329        assert_eq!(report.status, Status::Error);
330
331        // The appended tool-result message pairs BOTH ids.
332        let evs = dump(&events);
333        let answered: Vec<String> = evs
334            .iter()
335            .filter_map(|e| match e {
336                Event::Message { message } if message.role == Role::User => Some(&message.content),
337                _ => None,
338            })
339            .flatten()
340            .filter_map(|b| match b {
341                ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
342                _ => None,
343            })
344            .collect();
345        assert!(answered.iter().any(|id| id == "c_boom"));
346        assert!(
347            answered.iter().any(|id| id == "c_echo"),
348            "the un-run echo must be paired"
349        );
350        // boom recorded (ran, fatal); echo NOT recorded (never executed).
351        assert_eq!(report.tool_calls.len(), 1);
352    }
353
354    // ---- replay + stream fidelity ----
355
356    #[tokio::test]
357    async fn thinking_block_is_appended_verbatim() {
358        let completion = Completion {
359            content: vec![
360                ContentBlock::Reasoning {
361                    format: ReasoningFormat::Anthropic,
362                    text: "reasoning".into(),
363                    signature: Some("sig-xyz".into()),
364                    payload: None,
365                },
366                ContentBlock::Text {
367                    text: "answer".into(),
368                },
369            ],
370            usage: Usage::default(),
371            stop: StopReason::EndTurn,
372        };
373        let (mut s, events) = session_with(vec![Ok(completion)], Registry::new(), config());
374        let report = s.run_text("think").await;
375        assert_eq!(report.status, Status::Completed);
376        assert_eq!(report.final_message.as_deref(), Some("answer"));
377        // The emitted assistant message preserves the Thinking block + signature.
378        let has_thinking = dump(&events).iter().any(|e| match e {
379            Event::Message { message } if message.role == Role::Assistant => {
380                message.content.iter().any(|b| {
381                    matches!(
382                        b,
383                        ContentBlock::Reasoning { signature: Some(sig), .. } if sig == "sig-xyz"
384                    )
385                })
386            }
387            _ => false,
388        });
389        assert!(
390            has_thinking,
391            "thinking + signature must survive into history"
392        );
393    }
394
395    #[tokio::test]
396    async fn events_reconstruct_the_history() {
397        let (mut s, events) = session_with(
398            vec![Ok(tool_turn("c1", "echo")), Ok(text_turn("done"))],
399            echo_registry(),
400            config(),
401        );
402        let _ = s.run_text("go").await;
403        let rebuilt: Conversation = reconstruct_conversation(&dump(&events));
404        // user + assistant(tool_use) + user(tool_result) + assistant(text).
405        let roles: Vec<Role> = rebuilt.messages.iter().map(|m| m.role).collect();
406        assert_eq!(
407            roles,
408            vec![Role::User, Role::Assistant, Role::User, Role::Assistant]
409        );
410    }
411
412    #[tokio::test]
413    async fn usage_is_summed_across_turns() {
414        let mut first = tool_turn("c1", "echo");
415        first.usage = Usage {
416            input_tokens: 10,
417            output_tokens: 5,
418            ..Usage::default()
419        };
420        let mut second = text_turn("done");
421        second.usage = Usage {
422            input_tokens: 20,
423            output_tokens: 7,
424            ..Usage::default()
425        };
426        let (mut s, _e) = session_with(vec![Ok(first), Ok(second)], echo_registry(), config());
427        let report = s.run_text("go").await;
428        assert_eq!(report.usage.input_tokens, 30);
429        assert_eq!(report.usage.output_tokens, 12);
430    }
431}