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 HangingProvider;
954 #[async_trait]
955 impl Provider for HangingProvider {
956 #[allow(clippy::unnecessary_literal_bound)]
957 fn api_schema(&self) -> &str {
958 "mock"
959 }
960 async fn complete(
961 &self,
962 _request: &ConversationRequest,
963 ) -> Result<Completion, ProviderError> {
964 tokio::time::sleep(Duration::from_hours(1)).await;
965 Err(ProviderError::Transport("unreachable".into()))
966 }
967 }
968
969 struct WaitsForCancel;
972 #[async_trait]
973 impl Tool for WaitsForCancel {
974 type Args = Value;
975 type Output = EchoOut;
976 fn kind(&self) -> ToolKind {
977 ToolKind::Shell
978 }
979 fn description(&self) -> &str {
980 "waits"
981 }
982 async fn run(&self, ctx: &ToolCtx, _args: Value) -> Result<EchoOut, ToolError> {
983 ctx.cancel.cancelled().await;
984 Ok(EchoOut {
985 echoed: "stopped cooperatively".into(),
986 })
987 }
988 }
989
990 #[tokio::test]
991 async fn cancel_mid_sample_yields_cancelled_report() {
992 let events = Arc::new(Mutex::new(Vec::new()));
993 let sink_events = Arc::clone(&events);
994 let sink = Box::new(FnSink(move |event| {
995 sink_events.lock().unwrap().push(event);
996 }));
997 let mut s = Session::new(
998 Arc::new(HangingProvider),
999 Registry::new(),
1000 vec![],
1001 config(),
1002 sink,
1003 );
1004 let handle = s.cancel_handle();
1005 let canceller = tokio::spawn(async move {
1006 tokio::time::sleep(Duration::from_millis(20)).await;
1007 handle.cancel();
1008 handle.cancel(); });
1010 let report = s.run_text("go").await;
1011 canceller.await.expect("canceller");
1012
1013 assert_eq!(report.status, Status::Cancelled);
1014 assert_eq!(report.error, None, "cancelled is a stop, not a fault");
1015 assert_eq!(report.final_message, None, "no assistant text this run");
1016 assert_eq!(report.turns, 0, "no completion was accepted");
1017 let roles: Vec<Role> = s.history().iter().map(|m| m.role).collect();
1019 assert_eq!(roles, vec![Role::User]);
1020 let evs = dump(&events);
1022 assert!(
1023 matches!(evs.last(), Some(Event::Result { report }) if report.status == Status::Cancelled)
1024 );
1025 }
1026
1027 #[tokio::test]
1028 async fn cancel_mid_batch_pairs_the_rest_synthetically() {
1029 let mut reg = Registry::new();
1033 reg.register("waits", WaitsForCancel);
1034 reg.register("echo", Echo);
1035 let batch = Completion {
1036 content: vec![
1037 ContentBlock::ToolUse {
1038 id: "c_wait".into(),
1039 name: "waits".into(),
1040 input: json!({}),
1041 },
1042 ContentBlock::ToolUse {
1043 id: "c_echo".into(),
1044 name: "echo".into(),
1045 input: json!({}),
1046 },
1047 ],
1048 usage: Usage::default(),
1049 stop: StopReason::ToolUse,
1050 };
1051 let (s, events) = session_with(vec![Ok(batch)], reg, config());
1052 let mut s = s; let handle = s.cancel_handle();
1054 let canceller = tokio::spawn(async move {
1055 tokio::time::sleep(Duration::from_millis(20)).await;
1056 handle.cancel();
1057 });
1058 let report = s.run_text("go").await;
1059 canceller.await.expect("canceller");
1060
1061 assert_eq!(report.status, Status::Cancelled);
1062 assert_eq!(report.tool_calls.len(), 1);
1065 assert_eq!(report.tool_calls[0].id, "c_wait");
1066 assert!(report.tool_calls[0].ok);
1067 assert_eq!(report.tool_calls[0].denial_reason, None);
1068
1069 let pairs: Vec<(String, bool)> = dump(&events)
1071 .iter()
1072 .filter_map(|e| match e {
1073 Event::Message { message } if message.role == Role::User => Some(&message.content),
1074 _ => None,
1075 })
1076 .flatten()
1077 .filter_map(|b| match b {
1078 ContentBlock::ToolResult {
1079 tool_use_id,
1080 is_error,
1081 ..
1082 } => Some((tool_use_id.clone(), *is_error)),
1083 _ => None,
1084 })
1085 .collect();
1086 assert_eq!(
1087 pairs,
1088 vec![("c_wait".into(), false), ("c_echo".into(), true)]
1089 );
1090 assert_eq!(
1092 approvals(&events),
1093 vec![("c_wait".into(), "waits".into(), "allow".into())]
1094 );
1095 }
1096
1097 #[tokio::test]
1100 async fn cancelled_session_continues_on_the_next_run_with_a_fresh_token() {
1101 let mut reg = Registry::new();
1102 reg.register("waits", WaitsForCancel);
1103 let (s, _e) = session_with(
1104 vec![Ok(tool_turn("c1", "waits")), Ok(text_turn("second run"))],
1105 reg,
1106 config(),
1107 );
1108 let mut s = s;
1109 let handle1 = s.cancel_handle();
1110 let canceller = tokio::spawn(async move {
1111 tokio::time::sleep(Duration::from_millis(20)).await;
1112 handle1.cancel();
1113 });
1114 let r1 = s.run_text("q1").await;
1115 canceller.await.expect("canceller");
1116 assert_eq!(r1.status, Status::Cancelled);
1117
1118 assert!(!s.cancel_handle().is_cancelled());
1121 let r2 = s.run_text("q2").await;
1122 assert_eq!(r2.status, Status::Completed);
1123 assert_eq!(r2.final_message.as_deref(), Some("second run"));
1124 assert!(s.history().len() >= 4, "history: {:?}", s.history().len());
1126 }
1127
1128 struct CapturingProvider {
1133 inner: MockProvider,
1134 requests: Arc<Mutex<Vec<Vec<Message>>>>,
1135 }
1136 #[async_trait]
1137 impl Provider for CapturingProvider {
1138 #[allow(clippy::unnecessary_literal_bound)]
1139 fn api_schema(&self) -> &str {
1140 "mock"
1141 }
1142 async fn complete(
1143 &self,
1144 request: &ConversationRequest,
1145 ) -> Result<Completion, ProviderError> {
1146 self.requests.lock().unwrap().push(request.messages.clone());
1147 self.inner.complete(request).await
1148 }
1149 }
1150
1151 #[allow(clippy::type_complexity)]
1153 fn capturing_session_with(
1154 script: Vec<Result<Completion, ProviderError>>,
1155 registry: Registry,
1156 ) -> (
1157 Session,
1158 Arc<Mutex<Vec<Vec<Message>>>>,
1159 Arc<Mutex<Vec<Event>>>,
1160 ) {
1161 let requests = Arc::new(Mutex::new(Vec::new()));
1162 let events = Arc::new(Mutex::new(Vec::new()));
1163 let sink_events = Arc::clone(&events);
1164 let sink = Box::new(FnSink(move |event| {
1165 sink_events.lock().unwrap().push(event);
1166 }));
1167 let provider = Arc::new(CapturingProvider {
1168 inner: MockProvider::with_results(script),
1169 requests: Arc::clone(&requests),
1170 });
1171 let session = Session::new(provider, registry, vec![], config(), sink);
1172 (session, requests, events)
1173 }
1174
1175 fn user_text(message: &Message) -> Option<&str> {
1176 match (message.role, message.content.as_slice()) {
1177 (Role::User, [ContentBlock::Text { text }]) => Some(text.as_str()),
1178 _ => None,
1179 }
1180 }
1181
1182 #[tokio::test]
1183 async fn second_run_continues_the_conversation() {
1184 let (mut s, requests, _e) = capturing_session_with(
1185 vec![
1186 Ok(text_turn("first answer")),
1187 Ok(text_turn("second answer")),
1188 ],
1189 Registry::new(),
1190 );
1191 let r1 = s.run_text("q1").await;
1192 let r2 = s.run_text("q2").await;
1193 assert_eq!(r1.status, Status::Completed);
1194 assert_eq!(r2.status, Status::Completed);
1195 assert_eq!(r2.final_message.as_deref(), Some("second answer"));
1196
1197 let reqs = requests.lock().unwrap();
1199 assert_eq!(reqs.len(), 2);
1200 let run2 = &reqs[1];
1201 assert_eq!(run2.len(), 3, "user q1, assistant, user q2: {run2:?}");
1202 assert_eq!(user_text(&run2[0]), Some("q1"));
1203 assert_eq!(run2[1].role, Role::Assistant);
1204 assert_eq!(user_text(&run2[2]), Some("q2"));
1205
1206 let roles: Vec<Role> = s.history().iter().map(|m| m.role).collect();
1208 assert_eq!(
1209 roles,
1210 vec![Role::User, Role::Assistant, Role::User, Role::Assistant]
1211 );
1212 }
1213
1214 fn capturing_with_cfg(
1218 script: Vec<Result<Completion, ProviderError>>,
1219 cfg: EngineConfig,
1220 ) -> (Session, Arc<Mutex<Vec<Vec<Message>>>>) {
1221 let requests = Arc::new(Mutex::new(Vec::new()));
1222 let provider = Arc::new(CapturingProvider {
1223 inner: MockProvider::with_results(script),
1224 requests: Arc::clone(&requests),
1225 });
1226 let session = Session::new(provider, Registry::new(), vec![], cfg, Box::new(NullSink));
1227 (session, requests)
1228 }
1229
1230 fn instr_config(cwd: std::path::PathBuf) -> EngineConfig {
1233 EngineConfig {
1234 cwd,
1235 instructions: locode_instructions::InstructionsConfig {
1236 global_file: false,
1237 ..Default::default()
1238 },
1239 ..config()
1240 }
1241 }
1242
1243 fn reminder_text(msgs: &[Message]) -> Option<String> {
1245 msgs.iter()
1246 .find_map(|m| match (m.role, m.content.as_slice()) {
1247 (Role::User, [ContentBlock::Text { text }])
1248 if text.starts_with("<system-reminder>") =>
1249 {
1250 Some(text.clone())
1251 }
1252 _ => None,
1253 })
1254 }
1255
1256 fn reminder_count(msgs: &[Message]) -> usize {
1257 msgs.iter()
1258 .filter(|m| {
1259 matches!(
1260 (m.role, m.content.as_slice()),
1261 (Role::User, [ContentBlock::Text { text }]) if text.starts_with("<system-reminder>")
1262 )
1263 })
1264 .count()
1265 }
1266
1267 #[tokio::test]
1268 async fn project_instructions_injected_once_before_prompt() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let root = std::fs::canonicalize(dir.path()).unwrap();
1271 std::fs::create_dir(root.join(".git")).unwrap();
1272 std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1273
1274 let (mut s, requests) = capturing_with_cfg(
1275 vec![Ok(text_turn("ok1")), Ok(text_turn("ok2"))],
1276 instr_config(root),
1277 );
1278 s.run_text("q1").await;
1279 s.run_text("q2").await;
1280
1281 let reqs = requests.lock().unwrap();
1282 let run1 = &reqs[0];
1284 let rem = reminder_text(run1).expect("instructions injected on run 1");
1285 assert!(rem.contains("## From:"), "labeled: {rem}");
1286 assert!(rem.contains("be terse"), "content present: {rem}");
1287 let rem_idx = run1
1288 .iter()
1289 .position(|m| reminder_text(std::slice::from_ref(m)).is_some());
1290 let q1_idx = run1.iter().position(|m| user_text(m) == Some("q1"));
1291 assert!(rem_idx < q1_idx, "reminder comes before the prompt");
1292
1293 assert_eq!(reminder_count(&reqs[1]), 1, "not re-injected on run 2");
1295 }
1296
1297 fn resumed_with_cfg(
1300 script: Vec<Result<Completion, ProviderError>>,
1301 cfg: EngineConfig,
1302 replayed: Vec<Message>,
1303 ) -> (Session, Arc<Mutex<Vec<Vec<Message>>>>) {
1304 let requests = Arc::new(Mutex::new(Vec::new()));
1305 let provider = Arc::new(CapturingProvider {
1306 inner: MockProvider::with_results(script),
1307 requests: Arc::clone(&requests),
1308 });
1309 let session = Session::new(provider, Registry::new(), replayed, cfg, Box::new(NullSink));
1310 (session, requests)
1311 }
1312
1313 #[tokio::test]
1318 async fn resuming_does_not_re_inject_unchanged_instructions() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let root = std::fs::canonicalize(dir.path()).unwrap();
1321 std::fs::create_dir(root.join(".git")).unwrap();
1322 std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1323
1324 let (mut first, requests) =
1325 capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root.clone()));
1326 first.run_text("q1").await;
1327 let replayed = requests.lock().unwrap()[0].clone();
1328 assert_eq!(reminder_count(&replayed), 1, "precondition: injected once");
1329
1330 let (mut resumed, resumed_requests) =
1331 resumed_with_cfg(vec![Ok(text_turn("ok2"))], instr_config(root), replayed);
1332 resumed.run_text("q2").await;
1333 let reqs = resumed_requests.lock().unwrap();
1334 assert_eq!(
1335 reminder_count(&reqs[0]),
1336 1,
1337 "still the one from before the resume, not a second copy: {:#?}",
1338 reqs[0]
1339 );
1340 }
1341
1342 #[tokio::test]
1345 async fn resuming_re_injects_instructions_that_changed_while_away() {
1346 let dir = tempfile::tempdir().unwrap();
1347 let root = std::fs::canonicalize(dir.path()).unwrap();
1348 std::fs::create_dir(root.join(".git")).unwrap();
1349 std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1350
1351 let (mut first, requests) =
1352 capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root.clone()));
1353 first.run_text("q1").await;
1354 let replayed = requests.lock().unwrap()[0].clone();
1355
1356 std::fs::write(root.join("AGENTS.md"), "be verbose").unwrap();
1357 let (mut resumed, resumed_requests) =
1358 resumed_with_cfg(vec![Ok(text_turn("ok2"))], instr_config(root), replayed);
1359 resumed.run_text("q2").await;
1360 let reqs = resumed_requests.lock().unwrap();
1361 assert_eq!(reminder_count(&reqs[0]), 2, "the new body joins the old");
1362 let latest = reqs[0]
1363 .iter()
1364 .rev()
1365 .find_map(|m| reminder_text(std::slice::from_ref(m)))
1366 .expect("a reminder");
1367 assert!(latest.contains("be verbose"), "{latest}");
1368 assert!(
1369 latest.contains("replace all previously provided"),
1370 "banner present: {latest}"
1371 );
1372 }
1373
1374 #[tokio::test]
1377 async fn instructions_dropped_from_the_transcript_are_re_injected() {
1378 let dir = tempfile::tempdir().unwrap();
1379 let root = std::fs::canonicalize(dir.path()).unwrap();
1380 std::fs::create_dir(root.join(".git")).unwrap();
1381 std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1382
1383 let (mut first, requests) =
1384 capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root.clone()));
1385 first.run_text("q1").await;
1386 let compacted: Vec<Message> = requests.lock().unwrap()[0]
1388 .iter()
1389 .filter(|m| reminder_text(std::slice::from_ref(m)).is_none())
1390 .cloned()
1391 .collect();
1392
1393 let (mut after, after_requests) =
1394 resumed_with_cfg(vec![Ok(text_turn("ok2"))], instr_config(root), compacted);
1395 after.run_text("q2").await;
1396 assert_eq!(
1397 reminder_count(&after_requests.lock().unwrap()[0]),
1398 1,
1399 "re-injected after being compacted away"
1400 );
1401 }
1402
1403 fn skills_config(cwd: std::path::PathBuf) -> EngineConfig {
1408 EngineConfig {
1409 cwd: cwd.clone(),
1410 skills: locode_skills::SkillsConfig::enabled(),
1411 ..instr_config(cwd)
1412 }
1413 }
1414
1415 fn write_skill(root: &std::path::Path, name: &str, description: &str) {
1416 let dir = root.join(".agents/skills").join(name);
1417 std::fs::create_dir_all(&dir).unwrap();
1418 std::fs::write(
1419 dir.join("SKILL.md"),
1420 format!("---\nname: {name}\ndescription: {description}\n---\n# {name}\n"),
1421 )
1422 .unwrap();
1423 }
1424
1425 #[tokio::test]
1428 async fn skills_listing_injected_once_then_quiet() {
1429 let dir = tempfile::tempdir().unwrap();
1430 let root = std::fs::canonicalize(dir.path()).unwrap();
1431 std::fs::create_dir(root.join(".git")).unwrap();
1432 write_skill(&root, "commit", "Make a commit");
1433
1434 let (mut s, requests) = capturing_with_cfg(
1435 vec![Ok(text_turn("a")), Ok(text_turn("b"))],
1436 skills_config(root),
1437 );
1438 s.run_text("q1").await;
1439 s.run_text("q2").await;
1440
1441 let reqs = requests.lock().unwrap();
1442 let listing = reqs[0]
1443 .iter()
1444 .filter_map(|m| reminder_text(std::slice::from_ref(m)))
1445 .find(|t| t.contains("skills are available"))
1446 .expect("listing injected");
1447 assert!(listing.contains(r#"<skill name="commit""#), "{listing}");
1448 assert!(listing.contains("Make a commit"), "{listing}");
1449 assert!(
1450 listing.contains("SKILL.md\">"),
1451 "the path attribute: {listing}"
1452 );
1453
1454 let count = |msgs: &[Message]| {
1455 msgs.iter()
1456 .filter(|m| {
1457 reminder_text(std::slice::from_ref(m))
1458 .is_some_and(|t| t.contains("skills are available"))
1459 })
1460 .count()
1461 };
1462 assert_eq!(count(&reqs[1]), 1, "unchanged ⇒ not re-sent");
1463 }
1464
1465 #[tokio::test]
1473 async fn adding_a_skill_re_sends_the_entire_listing() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let root = std::fs::canonicalize(dir.path()).unwrap();
1476 std::fs::create_dir(root.join(".git")).unwrap();
1477 write_skill(&root, "commit", "Make a commit");
1478
1479 let (mut s, requests) = capturing_with_cfg(
1480 vec![Ok(text_turn("a")), Ok(text_turn("b")), Ok(text_turn("c"))],
1481 skills_config(root.clone()),
1482 );
1483 s.run_text("q1").await;
1484 write_skill(&root, "review", "Review a diff"); s.run_text("q2").await; s.run_text("q3").await;
1487
1488 let reqs = requests.lock().unwrap();
1489 let listing = |msgs: &[Message]| {
1490 msgs.iter()
1491 .filter_map(|m| reminder_text(std::slice::from_ref(m)))
1492 .rfind(|t| t.contains("skills are available"))
1493 };
1494 assert!(
1495 !listing(&reqs[1]).unwrap().contains(r#"name="review""#),
1496 "not yet — the scan that would see it runs at the end of this run"
1497 );
1498 let third = listing(&reqs[2]).expect("re-sent");
1499 assert!(
1500 third.contains(r#"name="commit""#),
1501 "old skill included: {third}"
1502 );
1503 assert!(
1504 third.contains(r#"name="review""#),
1505 "new skill included: {third}"
1506 );
1507 }
1508
1509 #[tokio::test]
1512 async fn removing_the_last_skill_announces_it() {
1513 let dir = tempfile::tempdir().unwrap();
1514 let root = std::fs::canonicalize(dir.path()).unwrap();
1515 std::fs::create_dir(root.join(".git")).unwrap();
1516 write_skill(&root, "commit", "Make a commit");
1517
1518 let (mut s, requests) = capturing_with_cfg(
1519 vec![Ok(text_turn("a")), Ok(text_turn("b")), Ok(text_turn("c"))],
1520 skills_config(root.clone()),
1521 );
1522 s.run_text("q1").await;
1523 std::fs::remove_dir_all(root.join(".agents/skills/commit")).unwrap();
1524 s.run_text("q2").await; s.run_text("q3").await;
1526
1527 let reqs = requests.lock().unwrap();
1528 let last = reqs[2]
1529 .iter()
1530 .filter_map(|m| reminder_text(std::slice::from_ref(m)))
1531 .next_back()
1532 .expect("a reminder");
1533 assert!(last.contains("No skills are currently available"), "{last}");
1534 }
1535
1536 #[tokio::test]
1538 async fn no_skills_ever_means_no_message_at_all() {
1539 let dir = tempfile::tempdir().unwrap();
1540 let root = std::fs::canonicalize(dir.path()).unwrap();
1541 std::fs::create_dir(root.join(".git")).unwrap();
1542
1543 let (mut s, requests) = capturing_with_cfg(vec![Ok(text_turn("a"))], skills_config(root));
1544 s.run_text("q1").await;
1545
1546 let reqs = requests.lock().unwrap();
1547 assert!(
1548 !reqs[0]
1549 .iter()
1550 .any(|m| reminder_text(std::slice::from_ref(m))
1551 .is_some_and(|t| t.contains("skills"))),
1552 "silence, not a denial"
1553 );
1554 }
1555
1556 #[tokio::test]
1557 async fn project_instructions_absent_when_disabled() {
1558 let dir = tempfile::tempdir().unwrap();
1559 let root = std::fs::canonicalize(dir.path()).unwrap();
1560 std::fs::create_dir(root.join(".git")).unwrap();
1561 std::fs::write(root.join("AGENTS.md"), "be terse").unwrap();
1562 let mut cfg = instr_config(root);
1563 cfg.instructions.enabled = false;
1564
1565 let (mut s, requests) = capturing_with_cfg(vec![Ok(text_turn("ok"))], cfg);
1566 s.run_text("q").await;
1567 assert!(reminder_text(&requests.lock().unwrap()[0]).is_none());
1568 }
1569
1570 #[tokio::test]
1571 async fn project_instructions_absent_when_no_agents_md() {
1572 let dir = tempfile::tempdir().unwrap();
1573 let root = std::fs::canonicalize(dir.path()).unwrap();
1574 let (mut s, requests) = capturing_with_cfg(vec![Ok(text_turn("ok"))], instr_config(root));
1576 s.run_text("q").await;
1577 assert!(reminder_text(&requests.lock().unwrap()[0]).is_none());
1578 }
1579
1580 #[tokio::test]
1581 async fn project_instructions_replace_banner_on_edit() {
1582 let dir = tempfile::tempdir().unwrap();
1583 let root = std::fs::canonicalize(dir.path()).unwrap();
1584 std::fs::create_dir(root.join(".git")).unwrap();
1585 let agents = root.join("AGENTS.md");
1586 std::fs::write(&agents, "v1 rules").unwrap();
1587
1588 let (mut s, requests) = capturing_with_cfg(
1589 vec![Ok(text_turn("ok1")), Ok(text_turn("ok2"))],
1590 instr_config(root),
1591 );
1592 s.run_text("q1").await;
1593 std::fs::write(&agents, "v2 rules").unwrap(); s.run_text("q2").await;
1595
1596 let reqs = requests.lock().unwrap();
1597 let run2 = &reqs[1];
1598 let banner = run2
1599 .iter()
1600 .find_map(|m| match (m.role, m.content.as_slice()) {
1601 (Role::User, [ContentBlock::Text { text }])
1602 if text.contains("replace all previously provided") =>
1603 {
1604 Some(text.clone())
1605 }
1606 _ => None,
1607 })
1608 .expect("replace banner on edit");
1609 assert!(banner.contains("v2 rules"), "new content: {banner}");
1610 assert!(!banner.contains("v1 rules"), "not the old content");
1611 assert_eq!(reminder_count(run2), 2);
1613 }
1614
1615 #[tokio::test]
1616 async fn project_instructions_removal_banner_on_delete() {
1617 let dir = tempfile::tempdir().unwrap();
1618 let root = std::fs::canonicalize(dir.path()).unwrap();
1619 std::fs::create_dir(root.join(".git")).unwrap();
1620 let agents = root.join("AGENTS.md");
1621 std::fs::write(&agents, "rules").unwrap();
1622
1623 let (mut s, requests) = capturing_with_cfg(
1624 vec![Ok(text_turn("ok1")), Ok(text_turn("ok2"))],
1625 instr_config(root),
1626 );
1627 s.run_text("q1").await;
1628 std::fs::remove_file(&agents).unwrap(); s.run_text("q2").await;
1630
1631 let reqs = requests.lock().unwrap();
1632 assert!(
1633 reqs[1].iter().any(|m| matches!(
1634 (m.role, m.content.as_slice()),
1635 (Role::User, [ContentBlock::Text { text }]) if text.contains("no longer apply")
1636 )),
1637 "removal notice on delete"
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn project_instructions_not_reinjected_when_unchanged() {
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"), "stable").unwrap();
1647
1648 let (mut s, requests) = capturing_with_cfg(
1649 vec![
1650 Ok(text_turn("ok1")),
1651 Ok(text_turn("ok2")),
1652 Ok(text_turn("ok3")),
1653 ],
1654 instr_config(root),
1655 );
1656 s.run_text("q1").await;
1657 s.run_text("q2").await;
1658 s.run_text("q3").await;
1659 assert_eq!(reminder_count(&requests.lock().unwrap()[2]), 1);
1661 }
1662
1663 #[tokio::test]
1664 async fn init_emitted_once_across_runs_with_one_result_each() {
1665 let (mut s, events) = session_with(
1666 vec![Ok(text_turn("one")), Ok(text_turn("two"))],
1667 Registry::new(),
1668 config(),
1669 );
1670 let _ = s.run_text("q1").await;
1671 let _ = s.run_text("q2").await;
1672 let evs = dump(&events);
1673 let inits = evs
1674 .iter()
1675 .filter(|e| matches!(e, Event::Init { .. }))
1676 .count();
1677 let results = evs
1678 .iter()
1679 .filter(|e| matches!(e, Event::Result { .. }))
1680 .count();
1681 assert_eq!(inits, 1, "Init is once per session, not per run");
1682 assert_eq!(results, 2, "one Result per run");
1683 assert!(
1684 matches!(evs.first(), Some(Event::Init { .. })),
1685 "Init still opens the stream"
1686 );
1687 }
1688
1689 #[tokio::test]
1690 async fn report_counts_are_per_run_not_cumulative() {
1691 let mut t1 = tool_turn("c1", "echo");
1694 t1.usage = Usage {
1695 input_tokens: 10,
1696 output_tokens: 5,
1697 ..Usage::default()
1698 };
1699 let t2 = text_turn("done one");
1700 let mut t3 = text_turn("done two");
1701 t3.usage = Usage {
1702 input_tokens: 20,
1703 output_tokens: 7,
1704 ..Usage::default()
1705 };
1706 let (mut s, _e) = session_with(vec![Ok(t1), Ok(t2), Ok(t3)], echo_registry(), config());
1707 let r1 = s.run_text("q1").await;
1708 let r2 = s.run_text("q2").await;
1709 assert_eq!(r1.turns, 2);
1710 assert_eq!(r1.tool_calls.len(), 1);
1711 assert_eq!(r2.turns, 1, "run 2 counts its own turns only");
1712 assert!(r2.tool_calls.is_empty());
1713 assert_eq!(r2.usage.input_tokens, 20, "usage is per-run");
1714 assert_eq!(r2.usage.output_tokens, 7);
1715 }
1716
1717 #[tokio::test]
1720 async fn two_run_stream_reconstructs_the_full_conversation() {
1721 let (mut s, events) = session_with(
1722 vec![
1723 Ok(tool_turn("c1", "echo")),
1724 Ok(text_turn("done one")),
1725 Ok(text_turn("done two")),
1726 ],
1727 echo_registry(),
1728 config(),
1729 );
1730 let _ = s.run_text("q1").await;
1731 let _ = s.run_text("q2").await;
1732 let rebuilt: Conversation = reconstruct_conversation(&dump(&events));
1733 let roles: Vec<Role> = rebuilt.messages.iter().map(|m| m.role).collect();
1736 assert_eq!(
1737 roles,
1738 vec![
1739 Role::User,
1740 Role::Assistant,
1741 Role::User,
1742 Role::Assistant,
1743 Role::User,
1744 Role::Assistant,
1745 ]
1746 );
1747 assert_eq!(rebuilt.messages.as_slice(), s.history());
1749 }
1750
1751 #[tokio::test]
1754 async fn continues_after_model_error() {
1755 let (mut s, requests, _e) = capturing_session_with(
1756 vec![Err(ProviderError::ContextOverflow), Ok(text_turn("ok now"))],
1757 Registry::new(),
1758 );
1759 let r1 = s.run_text("q1").await;
1760 let r2 = s.run_text("q2").await;
1761 assert_eq!(r1.status, Status::ModelError);
1762 assert_eq!(r2.status, Status::Completed);
1763 let reqs = requests.lock().unwrap();
1765 let run2 = &reqs[1];
1766 assert_eq!(run2.len(), 2, "user q1 + user q2: {run2:?}");
1767 assert_eq!(user_text(&run2[0]), Some("q1"));
1768 assert_eq!(user_text(&run2[1]), Some("q2"));
1769 }
1770
1771 #[tokio::test]
1774 async fn continues_after_fatal_tool_error_with_valid_pairing() {
1775 let mut reg = Registry::new();
1776 reg.register("boom", Boom);
1777 let (mut s, requests, _e) = capturing_session_with(
1778 vec![Ok(tool_turn("c1", "boom")), Ok(text_turn("recovered"))],
1779 reg,
1780 );
1781 let r1 = s.run_text("q1").await;
1782 let r2 = s.run_text("q2").await;
1783 assert_eq!(r1.status, Status::Error);
1784 assert_eq!(r2.status, Status::Completed);
1785
1786 let reqs = requests.lock().unwrap();
1789 let run2 = &reqs[1];
1790 assert_eq!(run2.len(), 4, "q1, assistant, tool_result, q2: {run2:?}");
1791 assert!(
1792 run2[1]
1793 .content
1794 .iter()
1795 .any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "c1"))
1796 );
1797 assert!(run2[2].content.iter().any(|b| matches!(
1798 b,
1799 ContentBlock::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "c1"
1800 )));
1801 assert_eq!(user_text(&run2[3]), Some("q2"));
1802 }
1803
1804 #[tokio::test]
1805 async fn usage_is_summed_across_turns() {
1806 let mut first = tool_turn("c1", "echo");
1807 first.usage = Usage {
1808 input_tokens: 10,
1809 output_tokens: 5,
1810 ..Usage::default()
1811 };
1812 let mut second = text_turn("done");
1813 second.usage = Usage {
1814 input_tokens: 20,
1815 output_tokens: 7,
1816 ..Usage::default()
1817 };
1818 let (mut s, _e) = session_with(vec![Ok(first), Ok(second)], echo_registry(), config());
1819 let report = s.run_text("go").await;
1820 assert_eq!(report.usage.input_tokens, 30);
1821 assert_eq!(report.usage.output_tokens, 12);
1822 }
1823
1824 #[tokio::test]
1832 async fn setting_the_model_announces_it_without_touching_the_preamble() {
1833 let preamble = vec![Message {
1834 role: Role::System,
1835 content: vec![ContentBlock::Text {
1836 text: "You are powered by the model old-1.".into(),
1837 }],
1838 }];
1839 let provider = Arc::new(MockProvider::with_results(vec![Ok(text_turn("ok"))]));
1840 let mut s = Session::new(
1841 provider.clone(),
1842 Registry::new(),
1843 preamble.clone(),
1844 config(),
1845 Box::new(NullSink),
1846 );
1847
1848 let notice = s.set_model(provider, "new-2");
1849 s.announce(notice);
1850
1851 assert_eq!(
1852 s.history()[0],
1853 preamble[0],
1854 "the preamble is untouched — the trace already recorded it"
1855 );
1856 let last = s.history().last().expect("announcement appended");
1857 assert_eq!(last.role, Role::User);
1858 let ContentBlock::Text { text } = &last.content[0] else {
1859 panic!("text block")
1860 };
1861 assert!(text.starts_with("<system-reminder>"), "{text}");
1862 assert!(text.contains("is now new-2"), "{text}");
1863 assert!(
1864 text.contains("out of date"),
1865 "corrects the stale line: {text}"
1866 );
1867 }
1868
1869 #[tokio::test]
1875 async fn context_usage_is_the_final_turn_not_the_sum() {
1876 let mut first = tool_turn("c1", "echo");
1877 first.usage = Usage {
1878 input_tokens: 10,
1879 output_tokens: 5,
1880 ..Usage::default()
1881 };
1882 let mut second = text_turn("done");
1883 second.usage = Usage {
1884 input_tokens: 20,
1885 output_tokens: 7,
1886 cache_read_tokens: Some(4),
1887 cache_creation_tokens: Some(3),
1888 ..Usage::default()
1889 };
1890 let (mut s, _e) = session_with(vec![Ok(first), Ok(second)], echo_registry(), config());
1891 let report = s.run_text("go").await;
1892
1893 assert_eq!(report.context_usage.input_tokens, 20, "the last turn only");
1894 assert_eq!(report.context_usage.output_tokens, 7);
1895 assert_eq!(
1896 report.context_usage.context_tokens(),
1897 20 + 4 + 3 + 7,
1898 "both cache counters are prompt tokens"
1899 );
1900 assert_eq!(report.usage.input_tokens, 30, "the sum is still the sum");
1901 }
1902}