1mod 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 #![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 #[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 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, ..EngineConfig::default()
117 }
118 }
119
120 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 #[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 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 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 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 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 assert_eq!(report.tool_calls.len(), 1);
246 assert!(!report.tool_calls[0].ok);
247 }
248
249 #[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 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 #[tokio::test]
305 async fn mid_batch_abort_synthesizes_results() {
306 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 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 assert_eq!(report.tool_calls.len(), 1);
352 }
353
354 #[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 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 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}