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