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