1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex, MutexGuard};
6
7use futures::{Stream, StreamExt};
8use motosan_agent_loop::{
9 AgentEvent, AgentOp, AgentSession, AgentStreamItem, AutocompactConfig, AutocompactExtension,
10 CoreEvent, Engine, LlmClient, SessionStore,
11};
12use motosan_agent_tool::ToolContext;
13use tokio::sync::mpsc;
14
15use crate::agent::build_system_prompt;
16use crate::config::Config;
17use crate::error::{AppError, Result};
18use crate::events::{ProgressChunk, UiEvent, UiToolResult};
19use crate::llm::build_llm_client;
20use crate::permissions::{NoOpPermissionGate, PermissionGate};
21use crate::tools::{builtin_tools, SharedCancelToken, ToolCtx, ToolProgressChunk};
22
23#[derive(Debug, Clone)]
25pub(crate) enum SessionMode {
26 New,
28 Resume(String),
30}
31
32struct SharedLlm {
37 client: Arc<dyn LlmClient>,
38}
39
40impl SharedLlm {
41 fn new(client: Arc<dyn LlmClient>) -> Self {
42 Self { client }
43 }
44
45 fn client(&self) -> Arc<dyn LlmClient> {
46 Arc::clone(&self.client)
47 }
48}
49
50pub(crate) struct SessionFactory {
51 cwd: PathBuf,
52 settings: crate::settings::Settings,
53 auth: crate::auth::Auth,
54 policy: Arc<crate::permissions::Policy>,
55 session_cache: Arc<crate::permissions::SessionCache>,
56 ui_tx: Option<mpsc::Sender<UiEvent>>,
57 permission_gate: Arc<dyn PermissionGate>,
58 progress_tx: mpsc::Sender<ToolProgressChunk>,
61 skills: Arc<Vec<crate::skills::Skill>>,
62 install_builtin_tools: bool,
63 extra_tools: Vec<Arc<dyn motosan_agent_tool::Tool>>,
64 max_iterations: usize,
65 context_discovery_disabled: bool,
66 autocompact_enabled: bool,
67 session_store: Option<Arc<dyn SessionStore>>,
68 llm_override: Option<Arc<dyn LlmClient>>,
69 current_model: Arc<Mutex<Option<crate::model::ModelId>>>,
70 cancel_token: SharedCancelToken,
71}
72
73impl SessionFactory {
74 fn current_model(&self) -> Option<crate::model::ModelId> {
75 match self.current_model.lock() {
76 Ok(guard) => guard.clone(),
77 Err(poisoned) => poisoned.into_inner().clone(),
78 }
79 }
80
81 fn set_current_model(&self, model: crate::model::ModelId) {
82 match self.current_model.lock() {
83 Ok(mut guard) => *guard = Some(model),
84 Err(poisoned) => *poisoned.into_inner() = Some(model),
85 }
86 }
87
88 async fn build(
93 &self,
94 mode: SessionMode,
95 model_override: Option<&crate::model::ModelId>,
96 ) -> Result<(AgentSession, Arc<dyn LlmClient>)> {
97 let effective_model = model_override.cloned().or_else(|| self.current_model());
99 let mut settings = self.settings.clone();
100 if let Some(m) = &effective_model {
101 settings.model.name = m.as_str().to_string();
102 }
103
104 let llm = if effective_model.is_none() {
105 self.llm_override.as_ref().map_or_else(
106 || build_llm_client(&settings, &self.auth),
107 |llm| Ok(Arc::clone(llm)),
108 )?
109 } else {
110 build_llm_client(&settings, &self.auth)?
111 };
112
113 let tool_ctx = ToolCtx::new_with_cancel_token(
116 &self.cwd,
117 Arc::clone(&self.permission_gate),
118 self.progress_tx.clone(),
119 self.cancel_token.clone(),
120 );
121 let mut tools = if self.install_builtin_tools {
122 builtin_tools(tool_ctx.clone())
123 } else {
124 Vec::new()
125 };
126 tools.extend(self.extra_tools.iter().cloned());
127
128 let tool_names: Vec<String> = tools.iter().map(|t| t.def().name).collect();
129 let base_prompt = build_system_prompt(&tool_names, &self.skills);
130 let system_prompt = if self.context_discovery_disabled {
131 base_prompt
132 } else {
133 let agent_dir = crate::paths::agent_dir();
134 let context = crate::context_files::load_project_context_files(&self.cwd, &agent_dir);
135 crate::context_files::assemble_system_prompt(&base_prompt, &context, &self.cwd)
136 };
137 let motosan_tool_context = ToolContext::new("capo", "capo").with_cwd(&self.cwd);
138
139 let mut engine_builder = Engine::builder()
140 .max_iterations(self.max_iterations)
141 .system_prompt(system_prompt)
142 .tool_context(motosan_tool_context);
143 for tool in tools {
144 engine_builder = engine_builder.tool(tool);
145 }
146 if let Some(ui_tx) = &self.ui_tx {
147 let ext = crate::permissions::PermissionExtension::new(
148 Arc::clone(&self.policy),
149 Arc::clone(&self.session_cache),
150 self.cwd.clone(),
151 ui_tx.clone(),
152 );
153 engine_builder = engine_builder.extension(Box::new(ext));
154 }
155 if self.autocompact_enabled
156 && settings.session.compact_at_context_pct > 0.0
157 && settings.session.compact_at_context_pct < 1.0
158 {
159 let cfg = AutocompactConfig {
160 threshold: settings.session.compact_at_context_pct,
161 max_context_tokens: settings.session.max_context_tokens,
162 keep_turns: settings.session.keep_turns.max(1),
163 };
164 engine_builder = engine_builder
165 .extension(Box::new(AutocompactExtension::new(cfg, Arc::clone(&llm))));
166 }
167 let engine = engine_builder.build();
168
169 let session = match (&mode, &self.session_store) {
170 (SessionMode::Resume(id), Some(store)) => {
171 let s = AgentSession::resume(id, Arc::clone(store), engine, Arc::clone(&llm))
172 .await
173 .map_err(|e| AppError::Config(format!("resume failed: {e}")))?;
174 let entries = s
175 .entries()
176 .await
177 .map_err(|e| AppError::Config(format!("entries failed: {e}")))?;
178 crate::session::hydrate_read_files(&entries, &tool_ctx).await?;
179 s
180 }
181 (SessionMode::Resume(_), None) => {
182 return Err(AppError::Config("resume requires a session store".into()));
183 }
184 (SessionMode::New, Some(store)) => {
185 let id = crate::session::SessionId::new();
186 AgentSession::new_with_store(
187 id.into_string(),
188 Arc::clone(store),
189 engine,
190 Arc::clone(&llm),
191 )
192 }
193 (SessionMode::New, None) => AgentSession::new(engine, Arc::clone(&llm)),
194 };
195
196 Ok((session, llm))
197 }
198}
199
200pub struct App {
201 session: arc_swap::ArcSwap<AgentSession>,
202 llm: arc_swap::ArcSwap<SharedLlm>,
203 factory: SessionFactory,
204 config: Config,
205 cancel_token: SharedCancelToken,
206 progress_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<ToolProgressChunk>>>,
207 next_tool_id: Arc<Mutex<ToolCallTracker>>,
208 skills: Arc<Vec<crate::skills::Skill>>,
209 mcp_servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
210 pub(crate) session_cache: Arc<crate::permissions::SessionCache>,
211}
212
213impl App {
214 pub fn config(&self) -> &Config {
215 &self.config
216 }
217
218 pub fn cancel(&self) {
222 self.cancel_token.cancel();
223 }
224
225 pub fn permissions_cache(&self) -> Arc<crate::permissions::SessionCache> {
229 Arc::clone(&self.session_cache)
230 }
231
232 pub fn session_id(&self) -> String {
235 self.session.load().session_id().to_string()
236 }
237
238 pub async fn session_history(
247 &self,
248 ) -> motosan_agent_loop::Result<Vec<motosan_agent_loop::Message>> {
249 self.session.load_full().history().await
250 }
251
252 pub async fn compact(&self) -> Result<()> {
258 use motosan_agent_loop::ThresholdStrategy;
259 let strategy = ThresholdStrategy {
260 threshold: 0.0,
261 ..ThresholdStrategy::default()
262 };
263 let llm = self.llm.load_full().client();
264 self.session
265 .load_full()
266 .maybe_compact(&strategy, llm)
267 .await
268 .map_err(|e| AppError::Config(format!("compaction failed: {e}")))?;
269 Ok(())
270 }
271
272 pub async fn new_session(&self) -> Result<()> {
276 let (session, llm) = self.factory.build(SessionMode::New, None).await?;
277 self.session.store(Arc::new(session));
278 self.llm.store(Arc::new(SharedLlm::new(llm)));
279 Ok(())
280 }
281
282 pub async fn load_session(&self, id: &str) -> Result<()> {
288 let (session, llm) = self
289 .factory
290 .build(SessionMode::Resume(id.to_string()), None)
291 .await?;
292 self.session.store(Arc::new(session));
293 self.llm.store(Arc::new(SharedLlm::new(llm)));
294 Ok(())
295 }
296
297 pub async fn switch_model(&self, model: &crate::model::ModelId) -> Result<()> {
312 let current_id = self.session.load().session_id().to_string();
313 let (session, llm) = self
314 .factory
315 .build(SessionMode::Resume(current_id), Some(model))
316 .await?;
317 self.factory.set_current_model(model.clone());
318 self.session.store(Arc::new(session));
319 self.llm.store(Arc::new(SharedLlm::new(llm)));
320 Ok(())
321 }
322
323 pub async fn disconnect_mcp(&self) {
326 for (name, server) in &self.mcp_servers {
327 let _ =
328 tokio::time::timeout(std::time::Duration::from_secs(2), server.disconnect()).await;
329 tracing::debug!(target: "mcp", server = %name, "disconnected");
330 }
331 }
332
333 pub fn send_user_message(&self, text: String) -> impl Stream<Item = UiEvent> + Send + 'static {
334 let session = self.session.load_full();
335 let skills = Arc::clone(&self.skills);
336 let cancel_token = self.cancel_token.clone();
337 let tracker = Arc::clone(&self.next_tool_id);
338 let progress = Arc::clone(&self.progress_rx);
339
340 async_stream::stream! {
341 let mut progress_guard = match progress.try_lock() {
343 Ok(guard) => guard,
344 Err(_) => {
345 yield UiEvent::Error(
346 "another turn is already running; capo is single-turn-per-App".into(),
347 );
348 return;
349 }
350 };
351
352 let cancel = cancel_token.reset();
354
355 yield UiEvent::AgentTurnStarted;
356 yield UiEvent::AgentThinking;
357
358 let history = match session.history().await {
360 Ok(h) => h,
361 Err(err) => {
362 yield UiEvent::Error(format!("session.history failed: {err}"));
363 return;
364 }
365 };
366 let mut messages = history;
367 let text = crate::skills::expand::expand_skill_command(&text, &skills);
368 messages.push(motosan_agent_loop::Message::user(&text));
369
370 let handle = match session.start_turn(messages).await {
372 Ok(h) => h,
373 Err(err) => {
374 yield UiEvent::Error(format!("session.start_turn failed: {err}"));
375 return;
376 }
377 };
378 let previous_len = handle.previous_len;
379 let epoch = handle.epoch;
380 let ops_tx = handle.ops_tx.clone();
381 let mut agent_stream = handle.stream;
382
383 let interrupt_bridge = tokio::spawn(async move {
391 cancel.cancelled().await;
392 let _ = ops_tx.send(AgentOp::Interrupt).await;
393 });
394
395 let mut terminal_messages: Option<Vec<motosan_agent_loop::Message>> = None;
397 let mut terminal_result: Option<motosan_agent_loop::Result<motosan_agent_loop::AgentResult>> = None;
398
399 loop {
400 while let Ok(chunk) = progress_guard.try_recv() {
402 yield UiEvent::ToolCallProgress {
403 id: progress_event_id(&tracker),
404 chunk: ProgressChunk::from(chunk),
405 };
406 }
407
408 tokio::select! {
409 biased;
410 maybe_item = agent_stream.next() => {
411 match maybe_item {
412 Some(AgentStreamItem::Event(ev)) => {
413 if let Some(ui) = map_event(ev, &tracker) {
414 yield ui;
415 }
416 }
417 Some(AgentStreamItem::Terminal(term)) => {
418 terminal_result = Some(term.result);
419 terminal_messages = Some(term.messages);
420 break;
421 }
422 None => break,
423 }
424 }
425 Some(chunk) = progress_guard.recv() => {
426 yield UiEvent::ToolCallProgress {
427 id: progress_event_id(&tracker),
428 chunk: ProgressChunk::from(chunk),
429 };
430 }
431 }
432 }
433
434 interrupt_bridge.abort();
436
437 if let Some(msgs) = terminal_messages.as_ref() {
439 if let Err(err) = session.record_turn_outcome(epoch, previous_len, msgs).await {
440 yield UiEvent::Error(format!("session.record_turn_outcome: {err}"));
441 }
442 }
443
444 match terminal_result {
446 Some(Ok(_)) => {
447 let final_text = terminal_messages
448 .as_ref()
449 .and_then(|msgs| {
450 msgs.iter()
451 .rev()
452 .find(|m| m.role() == motosan_agent_loop::Role::Assistant)
453 .map(|m| m.text())
454 })
455 .unwrap_or_default();
456 if !final_text.is_empty() {
457 yield UiEvent::AgentMessageComplete(final_text);
458 }
459 while let Ok(chunk) = progress_guard.try_recv() {
461 yield UiEvent::ToolCallProgress {
462 id: progress_event_id(&tracker),
463 chunk: ProgressChunk::from(chunk),
464 };
465 }
466 yield UiEvent::AgentTurnComplete;
467 }
468 Some(Err(err)) => {
469 yield UiEvent::Error(format!("{err}"));
470 }
471 None => { }
472 }
473 }
474 }
475}
476
477#[derive(Debug, Default)]
478struct ToolCallTracker {
479 next_id: usize,
480 pending: VecDeque<(String, String)>,
481}
482
483impl ToolCallTracker {
484 fn start(&mut self, name: &str) -> String {
485 self.next_id += 1;
486 let id = format!("tool_{}", self.next_id);
487 self.pending.push_back((name.to_string(), id.clone()));
488 id
489 }
490
491 fn complete(&mut self, name: &str) -> String {
492 if let Some(pos) = self
493 .pending
494 .iter()
495 .position(|(pending_name, _)| pending_name == name)
496 {
497 if let Some((_, id)) = self.pending.remove(pos) {
498 return id;
499 }
500 }
501
502 self.next_id += 1;
503 format!("tool_{}", self.next_id)
504 }
505
506 fn progress_id(&self) -> Option<String> {
511 match self.pending.len() {
512 1 => self.pending.front().map(|(_, id)| id.clone()),
513 _ => None,
514 }
515 }
516}
517
518fn lock_tool_tracker(tracker: &Arc<Mutex<ToolCallTracker>>) -> MutexGuard<'_, ToolCallTracker> {
519 match tracker.lock() {
520 Ok(guard) => guard,
521 Err(poisoned) => poisoned.into_inner(),
522 }
523}
524
525fn progress_event_id(tracker: &Arc<Mutex<ToolCallTracker>>) -> String {
526 lock_tool_tracker(tracker)
527 .progress_id()
528 .unwrap_or_else(|| "tool_unknown".to_string())
529}
530
531fn anthropic_api_key_from<F>(auth: &crate::auth::Auth, env_lookup: F) -> Option<String>
532where
533 F: Fn(&str) -> Option<String>,
534{
535 env_lookup("ANTHROPIC_API_KEY")
536 .map(|key| key.trim().to_string())
537 .filter(|key| !key.is_empty())
538 .or_else(|| auth.api_key("anthropic").map(str::to_string))
539}
540
541fn map_event(ev: AgentEvent, tool_tracker: &Arc<Mutex<ToolCallTracker>>) -> Option<UiEvent> {
542 match ev {
543 AgentEvent::Core(CoreEvent::TextChunk(delta)) => Some(UiEvent::AgentTextDelta(delta)),
544 AgentEvent::Core(CoreEvent::ToolStarted { name }) => {
545 let id = lock_tool_tracker(tool_tracker).start(&name);
546 Some(UiEvent::ToolCallStarted {
547 id,
548 name,
549 args: serde_json::json!({}),
550 })
551 }
552 AgentEvent::Core(CoreEvent::ToolCompleted { name, result }) => {
553 let id = lock_tool_tracker(tool_tracker).complete(&name);
554 Some(UiEvent::ToolCallCompleted {
555 id,
556 result: UiToolResult {
557 is_error: result.is_error,
558 text: format!("{name}: {result:?}"),
559 },
560 })
561 }
562 _ => None,
563 }
564}
565
566type CustomToolsFactory = Box<dyn FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>>>;
567
568pub struct AppBuilder {
569 config: Option<Config>,
570 cwd: Option<PathBuf>,
571 permission_gate: Option<Arc<dyn PermissionGate>>,
572 install_builtin_tools: bool,
573 max_iterations: usize,
574 llm_override: Option<Arc<dyn LlmClient>>,
575 custom_tools_factory: Option<CustomToolsFactory>,
576 permissions_policy_path: Option<PathBuf>,
577 ui_tx: Option<mpsc::Sender<crate::events::UiEvent>>,
578 settings: Option<crate::settings::Settings>,
579 auth: Option<crate::auth::Auth>,
580 context_discovery_disabled: bool,
581 session_store: Option<Arc<dyn SessionStore>>,
583 resume_session_id: Option<crate::session::SessionId>,
584 autocompact_enabled: bool,
585 skills: Vec<crate::skills::Skill>,
587 extra_tools: Vec<Arc<dyn motosan_agent_tool::Tool>>,
589 mcp_servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
590}
591
592impl Default for AppBuilder {
593 fn default() -> Self {
594 Self {
595 config: None,
596 cwd: None,
597 permission_gate: None,
598 install_builtin_tools: false,
599 max_iterations: 20,
600 llm_override: None,
601 custom_tools_factory: None,
602 permissions_policy_path: None,
603 ui_tx: None,
604 settings: None,
605 auth: None,
606 context_discovery_disabled: false,
607 session_store: None,
608 resume_session_id: None,
609 autocompact_enabled: false,
610 skills: Vec::new(),
611 extra_tools: Vec::new(),
612 mcp_servers: Vec::new(),
613 }
614 }
615}
616
617impl AppBuilder {
618 pub fn new() -> Self {
619 Self::default()
620 }
621
622 pub fn with_config(mut self, cfg: Config) -> Self {
623 self.config = Some(cfg);
624 self
625 }
626
627 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
628 self.cwd = Some(cwd.into());
629 self
630 }
631
632 pub fn with_permission_gate(mut self, gate: Arc<dyn PermissionGate>) -> Self {
633 self.permission_gate = Some(gate);
634 self
635 }
636
637 pub fn with_builtin_tools(mut self) -> Self {
643 self.install_builtin_tools = true;
644 self
645 }
646
647 pub fn with_max_iterations(mut self, n: usize) -> Self {
648 self.max_iterations = n;
649 self
650 }
651
652 pub fn with_llm(mut self, llm: Arc<dyn LlmClient>) -> Self {
653 self.llm_override = Some(llm);
654 self
655 }
656
657 pub fn with_permissions_config(mut self, path: PathBuf) -> Self {
658 self.permissions_policy_path = Some(path);
659 self
660 }
661
662 pub fn with_ui_channel(mut self, tx: mpsc::Sender<UiEvent>) -> Self {
663 self.ui_tx = Some(tx);
664 self
665 }
666
667 pub fn with_settings(mut self, settings: crate::settings::Settings) -> Self {
669 self.settings = Some(settings);
670 self
671 }
672
673 pub fn with_auth(mut self, auth: crate::auth::Auth) -> Self {
675 self.auth = Some(auth);
676 self
677 }
678
679 pub fn disable_context_discovery(mut self) -> Self {
682 self.context_discovery_disabled = true;
683 self
684 }
685
686 pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
689 self.session_store = Some(store);
690 self
691 }
692
693 pub fn with_autocompact(mut self) -> Self {
698 self.autocompact_enabled = true;
699 self
700 }
701
702 pub fn with_skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
708 self.skills = skills;
709 self
710 }
711
712 pub fn without_skills(mut self) -> Self {
713 self.skills.clear();
714 self
715 }
716
717 pub fn with_extra_tools(mut self, tools: Vec<Arc<dyn motosan_agent_tool::Tool>>) -> Self {
721 self.extra_tools = tools;
722 self
723 }
724
725 pub fn with_mcp_servers(
728 mut self,
729 servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
730 ) -> Self {
731 self.mcp_servers = servers;
732 self
733 }
734
735 pub fn with_custom_tools_factory(
740 mut self,
741 factory: impl FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>> + 'static,
742 ) -> Self {
743 self.custom_tools_factory = Some(Box::new(factory));
744 self
745 }
746
747 pub async fn build_with_custom_tools(
751 self,
752 factory: impl FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>> + 'static,
753 ) -> Result<App> {
754 self.with_custom_tools_factory(factory).build().await
755 }
756
757 pub async fn build_with_session(
765 mut self,
766 resume: Option<crate::session::SessionId>,
767 ) -> Result<App> {
768 if let Some(id) = resume {
769 if self.session_store.is_none() {
770 return Err(AppError::Config(
771 "build_with_session(Some(id)) requires with_session_store(...)".into(),
772 ));
773 }
774 self.resume_session_id = Some(id);
775 }
776 self.build_internal().await
777 }
778
779 pub async fn build(self) -> Result<App> {
781 self.build_with_session(None).await
782 }
783
784 async fn build_internal(mut self) -> Result<App> {
785 let mcp_servers = std::mem::take(&mut self.mcp_servers);
786 let extra_tools = std::mem::take(&mut self.extra_tools);
787 let skills = self.skills.clone();
788 if self.install_builtin_tools && self.custom_tools_factory.is_some() {
789 return Err(AppError::Config(
790 "with_builtin_tools and with_custom_tools_factory are mutually exclusive".into(),
791 ));
792 }
793
794 let has_config = self.config.is_some();
798 let has_auth = self.auth.is_some();
799 let mut config = self.config.unwrap_or_default();
800 let settings = match self.settings {
801 Some(settings) => settings,
802 None => {
803 let mut settings = crate::settings::Settings::default();
804 settings.model.provider = config.model.provider.clone();
805 settings.model.name = config.model.name.clone();
806 settings.model.max_tokens = config.model.max_tokens;
807 settings
808 }
809 };
810 config.model.provider = settings.model.provider.clone();
811 config.model.name = settings.model.name.clone();
812 config.model.max_tokens = settings.model.max_tokens;
813 let mut auth = self.auth.unwrap_or_default();
814 if !has_auth {
815 if let Some(key) = config.anthropic.api_key.as_deref() {
816 auth.0.insert(
817 "anthropic".into(),
818 crate::auth::ProviderAuth::ApiKey {
819 key: key.to_string(),
820 },
821 );
822 }
823 }
824 let env_or_auth_key = anthropic_api_key_from(&auth, |name| std::env::var(name).ok());
825 if env_or_auth_key.is_some() || has_auth || !has_config {
826 config.anthropic.api_key = env_or_auth_key;
827 }
828 let cwd = self
829 .cwd
830 .or_else(|| std::env::current_dir().ok())
831 .unwrap_or_else(|| PathBuf::from("."));
832 let permission_gate = self.permission_gate.unwrap_or_else(|| {
833 if self.ui_tx.is_some() {
837 Arc::new(NoOpPermissionGate) as Arc<dyn PermissionGate>
838 } else {
839 tracing::warn!("no PermissionGate and no UI channel — tools run unchecked");
840 Arc::new(NoOpPermissionGate) as Arc<dyn PermissionGate>
841 }
842 });
843
844 let policy: Arc<crate::permissions::Policy> =
846 Arc::new(match self.permissions_policy_path.as_ref() {
847 Some(path) => crate::permissions::Policy::load_or_default(path)?,
848 None => crate::permissions::Policy::default(),
849 });
850 let session_cache = Arc::new(crate::permissions::SessionCache::new());
851
852 let (progress_tx, progress_rx) = mpsc::channel::<ToolProgressChunk>(64);
854
855 let probe_ctx = ToolCtx::new(&cwd, Arc::clone(&permission_gate), progress_tx.clone());
860 let cancel_token = probe_ctx.cancel_token.clone();
861 let (install_builtin, factory_extra_tools): (bool, Vec<Arc<dyn motosan_agent_tool::Tool>>) =
862 if let Some(factory_fn) = self.custom_tools_factory.take() {
863 let mut t = factory_fn(probe_ctx);
864 t.extend(extra_tools.clone());
865 (false, t)
866 } else {
867 (self.install_builtin_tools, extra_tools.clone())
868 };
869
870 let factory = SessionFactory {
871 cwd: cwd.clone(),
872 settings: settings.clone(),
873 auth: auth.clone(),
874 policy: Arc::clone(&policy),
875 session_cache: Arc::clone(&session_cache),
876 ui_tx: self.ui_tx.clone(),
877 permission_gate: Arc::clone(&permission_gate),
878 progress_tx: progress_tx.clone(),
879 skills: Arc::new(skills.clone()),
880 install_builtin_tools: install_builtin,
881 extra_tools: factory_extra_tools,
882 max_iterations: self.max_iterations,
883 context_discovery_disabled: self.context_discovery_disabled,
884 autocompact_enabled: self.autocompact_enabled,
885 session_store: self.session_store.clone(),
886 llm_override: self.llm_override.clone(),
887 current_model: Arc::new(Mutex::new(None)),
888 cancel_token: cancel_token.clone(),
889 };
890
891 let mode = match self.resume_session_id.take() {
892 Some(id) => SessionMode::Resume(id.into_string()),
893 None => SessionMode::New,
894 };
895 let (session, llm) = factory.build(mode, None).await?;
896
897 Ok(App {
898 session: arc_swap::ArcSwap::from_pointee(session),
899 llm: arc_swap::ArcSwap::from_pointee(SharedLlm::new(llm)),
900 factory,
901 config,
902 cancel_token,
903 progress_rx: Arc::new(tokio::sync::Mutex::new(progress_rx)),
904 next_tool_id: Arc::new(Mutex::new(ToolCallTracker::default())),
905 skills: Arc::new(skills),
906 mcp_servers,
907 session_cache,
908 })
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915 use crate::config::{AnthropicConfig, ModelConfig};
916 use crate::events::UiEvent;
917 use async_trait::async_trait;
918 use motosan_agent_loop::{ChatOutput, LlmClient, LlmResponse, Message, ToolCallItem};
919 use motosan_agent_tool::ToolDef;
920 use std::sync::atomic::{AtomicUsize, Ordering};
921
922 #[tokio::test]
923 async fn builder_fails_without_api_key() {
924 let cfg = Config {
925 anthropic: AnthropicConfig {
926 api_key: None,
927 base_url: "https://api.anthropic.com".into(),
928 },
929 model: ModelConfig {
930 provider: "anthropic".into(),
931 name: "claude-sonnet-4-6".into(),
932 max_tokens: 4096,
933 },
934 };
935 let err = match AppBuilder::new()
936 .with_config(cfg)
937 .with_builtin_tools()
938 .build()
939 .await
940 {
941 Ok(_) => panic!("must fail without key"),
942 Err(err) => err,
943 };
944 assert!(format!("{err}").contains("ANTHROPIC_API_KEY"));
945 }
946
947 struct ToolOnlyLlm {
948 turn: AtomicUsize,
949 }
950
951 #[async_trait]
952 impl LlmClient for ToolOnlyLlm {
953 async fn chat(
954 &self,
955 _messages: &[Message],
956 _tools: &[ToolDef],
957 ) -> motosan_agent_loop::Result<ChatOutput> {
958 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
959 if turn == 0 {
960 Ok(ChatOutput::new(LlmResponse::ToolCalls(vec![
961 ToolCallItem {
962 id: "t1".into(),
963 name: "read".into(),
964 args: serde_json::json!({"path":"nope.txt"}),
965 },
966 ])))
967 } else {
968 Ok(ChatOutput::new(LlmResponse::Message(String::new())))
969 }
970 }
971 }
972
973 #[tokio::test]
974 async fn empty_final_message_is_not_emitted() {
975 let dir = tempfile::tempdir().unwrap();
976 let mut cfg = Config::default();
977 cfg.anthropic.api_key = Some("sk-unused".into());
978 let app = AppBuilder::new()
979 .with_config(cfg)
980 .with_cwd(dir.path())
981 .with_builtin_tools()
982 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
983 turn: AtomicUsize::new(0),
984 }))
985 .build()
986 .await
987 .expect("build");
988 let events: Vec<UiEvent> =
989 futures::StreamExt::collect(app.send_user_message("x".into())).await;
990 let empties = events
991 .iter()
992 .filter(|e| matches!(e, UiEvent::AgentMessageComplete(t) if t.is_empty()))
993 .count();
994 assert_eq!(
995 empties, 0,
996 "should not emit empty final message, got: {events:?}"
997 );
998 }
999
1000 struct EchoLlm;
1001
1002 #[async_trait]
1003 impl LlmClient for EchoLlm {
1004 async fn chat(
1005 &self,
1006 _messages: &[Message],
1007 _tools: &[ToolDef],
1008 ) -> motosan_agent_loop::Result<ChatOutput> {
1009 Ok(ChatOutput::new(LlmResponse::Message("ok".into())))
1010 }
1011 }
1012
1013 #[tokio::test]
1014 async fn new_session_swaps_in_a_fresh_empty_session() {
1015 use futures::StreamExt;
1016 let dir = tempfile::tempdir().expect("tempdir");
1017 let mut config = Config::default();
1018 config.anthropic.api_key = Some("sk-unused".into());
1019 let app = AppBuilder::new()
1020 .with_config(config)
1021 .with_cwd(dir.path())
1022 .with_builtin_tools()
1023 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
1024 .build()
1025 .await
1026 .expect("build");
1027
1028 let _: Vec<_> = app.send_user_message("hello".into()).collect().await;
1029 let id_before = app.session_id();
1030 assert!(!app.session_history().await.expect("history").is_empty());
1031
1032 app.new_session().await.expect("new_session");
1033
1034 assert_ne!(app.session_id(), id_before, "a fresh session has a new id");
1035 assert!(
1036 app.session_history().await.expect("history").is_empty(),
1037 "fresh session has no history"
1038 );
1039 }
1040
1041 #[tokio::test]
1042 async fn load_session_restores_a_stored_session_by_id() {
1043 use futures::StreamExt;
1044 let dir = tempfile::tempdir().expect("tempdir");
1045 let store_dir = dir.path().join("sessions");
1046 let store: Arc<dyn SessionStore> =
1047 Arc::new(motosan_agent_loop::FileSessionStore::new(store_dir));
1048 let mut config = Config::default();
1049 config.anthropic.api_key = Some("sk-unused".into());
1050 let app = AppBuilder::new()
1051 .with_config(config)
1052 .with_cwd(dir.path())
1053 .with_builtin_tools()
1054 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
1055 .with_session_store(Arc::clone(&store))
1056 .build()
1057 .await
1058 .expect("build");
1059
1060 let _: Vec<_> = app
1061 .send_user_message("remember this".into())
1062 .collect()
1063 .await;
1064 let original_id = app.session_id();
1065
1066 app.new_session().await.expect("new_session");
1067 assert_ne!(app.session_id(), original_id);
1068
1069 app.load_session(&original_id).await.expect("load_session");
1070 assert_eq!(app.session_id(), original_id);
1071 let history = app.session_history().await.expect("history");
1072 assert!(
1073 history.iter().any(|m| m.text().contains("remember this")),
1074 "loaded session should carry the original turn"
1075 );
1076 }
1077
1078 #[tokio::test]
1079 async fn switch_model_preserves_history() {
1080 use futures::StreamExt;
1081 let dir = tempfile::tempdir().expect("tempdir");
1082 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
1083 dir.path().join("s"),
1084 ));
1085 let mut config = Config::default();
1086 config.anthropic.api_key = Some("sk-unused".into());
1087 let app = AppBuilder::new()
1088 .with_config(config)
1089 .with_cwd(dir.path())
1090 .with_builtin_tools()
1091 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
1092 .with_session_store(store)
1093 .build()
1094 .await
1095 .expect("build");
1096
1097 let _: Vec<_> = app.send_user_message("keep me".into()).collect().await;
1098 let id_before = app.session_id();
1099
1100 app.switch_model(&crate::model::ModelId::from("claude-opus-4-7"))
1101 .await
1102 .expect("switch_model");
1103
1104 assert_eq!(
1105 app.session_id(),
1106 id_before,
1107 "switch_model keeps the same session"
1108 );
1109 let history = app.session_history().await.expect("history");
1110 assert!(history.iter().any(|m| m.text().contains("keep me")));
1111 }
1112
1113 #[tokio::test]
1114 async fn switch_model_is_sticky_for_future_session_rebuilds() {
1115 let dir = tempfile::tempdir().expect("tempdir");
1116 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
1117 dir.path().join("s"),
1118 ));
1119 let mut config = Config::default();
1120 config.anthropic.api_key = Some("sk-unused".into());
1121 let app = AppBuilder::new()
1122 .with_config(config)
1123 .with_cwd(dir.path())
1124 .with_builtin_tools()
1125 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
1126 .with_session_store(store)
1127 .build()
1128 .await
1129 .expect("build");
1130
1131 let selected = crate::model::ModelId::from("claude-opus-4-7");
1132 app.switch_model(&selected).await.expect("switch_model");
1133 app.new_session().await.expect("new_session");
1134
1135 assert_eq!(app.factory.current_model(), Some(selected));
1136 }
1137
1138 struct SleepThenDoneLlm {
1139 turn: AtomicUsize,
1140 }
1141
1142 #[async_trait]
1143 impl LlmClient for SleepThenDoneLlm {
1144 async fn chat(
1145 &self,
1146 _messages: &[Message],
1147 _tools: &[ToolDef],
1148 ) -> motosan_agent_loop::Result<ChatOutput> {
1149 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
1150 if turn == 0 {
1151 Ok(ChatOutput::new(LlmResponse::ToolCalls(vec![
1152 ToolCallItem {
1153 id: "sleep".into(),
1154 name: "bash".into(),
1155 args: serde_json::json!({"command":"sleep 5", "timeout_ms": 10000}),
1156 },
1157 ])))
1158 } else {
1159 Ok(ChatOutput::new(LlmResponse::Message("done".into())))
1160 }
1161 }
1162 }
1163
1164 #[tokio::test]
1165 async fn cancel_reaches_builtin_tools_after_session_rebuild() {
1166 use futures::StreamExt;
1167 let dir = tempfile::tempdir().expect("tempdir");
1168 let mut config = Config::default();
1169 config.anthropic.api_key = Some("sk-unused".into());
1170 let app = Arc::new(
1171 AppBuilder::new()
1172 .with_config(config)
1173 .with_cwd(dir.path())
1174 .with_builtin_tools()
1175 .with_llm(Arc::new(SleepThenDoneLlm {
1176 turn: AtomicUsize::new(0),
1177 }) as Arc<dyn LlmClient>)
1178 .build()
1179 .await
1180 .expect("build"),
1181 );
1182
1183 app.new_session().await.expect("new_session");
1184 let running_app = Arc::clone(&app);
1185 let handle = tokio::spawn(async move {
1186 running_app
1187 .send_user_message("run a slow command".into())
1188 .collect::<Vec<_>>()
1189 .await
1190 });
1191 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1192 app.cancel();
1193
1194 let events = tokio::time::timeout(std::time::Duration::from_secs(2), handle)
1195 .await
1196 .expect("turn should finish after cancellation")
1197 .expect("join");
1198 assert!(
1199 events.iter().any(|event| {
1200 matches!(
1201 event,
1202 UiEvent::ToolCallCompleted { result, .. }
1203 if result.text.contains("command cancelled by user")
1204 )
1205 }),
1206 "cancel should reach the rebuilt bash tool: {events:?}"
1207 );
1208 }
1209
1210 #[tokio::test]
1211 async fn compact_summarizes_a_session_with_enough_history() {
1212 struct DoneLlm;
1213 #[async_trait]
1214 impl LlmClient for DoneLlm {
1215 async fn chat(
1216 &self,
1217 _messages: &[Message],
1218 _tools: &[ToolDef],
1219 ) -> motosan_agent_loop::Result<ChatOutput> {
1220 Ok(ChatOutput::new(LlmResponse::Message("done".into())))
1221 }
1222 }
1223
1224 let dir = tempfile::tempdir().expect("tempdir");
1225 let store = Arc::new(motosan_agent_loop::FileSessionStore::new(
1226 dir.path().join("sessions"),
1227 ));
1228 let mut config = Config::default();
1229 config.anthropic.api_key = Some("sk-unused".into());
1230 let app = AppBuilder::new()
1231 .with_config(config)
1232 .with_cwd(dir.path())
1233 .with_builtin_tools()
1234 .with_llm(Arc::new(DoneLlm) as Arc<dyn LlmClient>)
1235 .with_session_store(store)
1236 .build()
1237 .await
1238 .expect("build");
1239
1240 for i in 0..4 {
1247 let _: Vec<_> = app.send_user_message(format!("turn {i}")).collect().await;
1248 }
1249
1250 app.compact().await.expect("compact should succeed");
1251
1252 let history = app.session_history().await.expect("history");
1255 assert!(
1256 !history.is_empty(),
1257 "session should still have content post-compaction"
1258 );
1259 }
1260
1261 #[test]
1262 fn anthropic_env_api_key_overrides_auth_json_key() {
1263 let mut auth = crate::auth::Auth::default();
1264 auth.0.insert(
1265 "anthropic".into(),
1266 crate::auth::ProviderAuth::ApiKey {
1267 key: "sk-auth".into(),
1268 },
1269 );
1270
1271 let key = anthropic_api_key_from(&auth, |name| {
1272 (name == "ANTHROPIC_API_KEY").then(|| " sk-env ".to_string())
1273 });
1274 assert_eq!(key.as_deref(), Some("sk-env"));
1275 }
1276
1277 #[tokio::test]
1278 async fn with_settings_overrides_deprecated_config_model() {
1279 use crate::settings::Settings;
1280
1281 let mut config = Config::default();
1282 config.model.name = "from-config".into();
1283 config.anthropic.api_key = Some("sk-config".into());
1284
1285 let mut settings = Settings::default();
1286 settings.model.name = "from-settings".into();
1287
1288 let tmp = tempfile::tempdir().unwrap();
1289 let app = AppBuilder::new()
1290 .with_config(config)
1291 .with_settings(settings)
1292 .with_cwd(tmp.path())
1293 .disable_context_discovery()
1294 .with_llm(Arc::new(EchoLlm))
1295 .build()
1296 .await
1297 .expect("build");
1298 assert_eq!(app.config().model.name, "from-settings");
1299 assert_eq!(app.config().anthropic.api_key.as_deref(), Some("sk-config"));
1300 }
1301
1302 #[tokio::test]
1303 async fn with_settings_synthesises_legacy_config_for_build() {
1304 use crate::auth::{Auth, ProviderAuth};
1305 use crate::settings::Settings;
1306
1307 let mut settings = Settings::default();
1308 settings.model.name = "claude-sonnet-4-6".into();
1309
1310 let mut auth = Auth::default();
1311 auth.0.insert(
1312 "anthropic".into(),
1313 ProviderAuth::ApiKey {
1314 key: "sk-test".into(),
1315 },
1316 );
1317
1318 let tmp = tempfile::tempdir().unwrap();
1319 let app = AppBuilder::new()
1320 .with_settings(settings)
1321 .with_auth(auth)
1322 .with_cwd(tmp.path())
1323 .with_builtin_tools()
1324 .disable_context_discovery()
1325 .with_llm(Arc::new(EchoLlm))
1326 .build()
1327 .await
1328 .expect("build");
1329 let _ = app;
1330 }
1331
1332 #[tokio::test]
1333 async fn cancel_before_turn_does_not_poison_future_turns() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let mut cfg = Config::default();
1336 cfg.anthropic.api_key = Some("sk-unused".into());
1337 let app = AppBuilder::new()
1338 .with_config(cfg)
1339 .with_cwd(dir.path())
1340 .with_builtin_tools()
1341 .with_llm(std::sync::Arc::new(EchoLlm))
1342 .build()
1343 .await
1344 .expect("build");
1345
1346 app.cancel();
1347 let events: Vec<UiEvent> = app.send_user_message("x".into()).collect().await;
1348
1349 assert!(
1350 events
1351 .iter()
1352 .any(|e| matches!(e, UiEvent::AgentMessageComplete(text) if text == "ok")),
1353 "turn should use a fresh cancellation token: {events:?}"
1354 );
1355 }
1356
1357 #[test]
1358 fn map_event_matches_started_and_completed_ids_by_tool_name() {
1359 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
1360
1361 let started_bash = map_event(
1362 AgentEvent::Core(CoreEvent::ToolStarted {
1363 name: "bash".into(),
1364 }),
1365 &tracker,
1366 );
1367 let started_read = map_event(
1368 AgentEvent::Core(CoreEvent::ToolStarted {
1369 name: "read".into(),
1370 }),
1371 &tracker,
1372 );
1373 let completed_bash = map_event(
1374 AgentEvent::Core(CoreEvent::ToolCompleted {
1375 name: "bash".into(),
1376 result: motosan_agent_tool::ToolResult::text("ok"),
1377 }),
1378 &tracker,
1379 );
1380 let completed_read = map_event(
1381 AgentEvent::Core(CoreEvent::ToolCompleted {
1382 name: "read".into(),
1383 result: motosan_agent_tool::ToolResult::text("ok"),
1384 }),
1385 &tracker,
1386 );
1387
1388 assert!(matches!(
1389 started_bash,
1390 Some(UiEvent::ToolCallStarted { ref id, ref name, .. }) if id == "tool_1" && name == "bash"
1391 ));
1392 assert!(matches!(
1393 started_read,
1394 Some(UiEvent::ToolCallStarted { ref id, ref name, .. }) if id == "tool_2" && name == "read"
1395 ));
1396 assert!(matches!(
1397 completed_bash,
1398 Some(UiEvent::ToolCallCompleted { ref id, .. }) if id == "tool_1"
1399 ));
1400 assert!(matches!(
1401 completed_read,
1402 Some(UiEvent::ToolCallCompleted { ref id, .. }) if id == "tool_2"
1403 ));
1404 }
1405
1406 #[test]
1407 fn tool_tracker_handles_two_concurrent_invocations_of_same_tool() {
1408 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
1409 let s1 = map_event(
1410 AgentEvent::Core(CoreEvent::ToolStarted {
1411 name: "bash".into(),
1412 }),
1413 &tracker,
1414 );
1415 let s2 = map_event(
1416 AgentEvent::Core(CoreEvent::ToolStarted {
1417 name: "bash".into(),
1418 }),
1419 &tracker,
1420 );
1421 let c1 = map_event(
1422 AgentEvent::Core(CoreEvent::ToolCompleted {
1423 name: "bash".into(),
1424 result: motosan_agent_tool::ToolResult::text("a"),
1425 }),
1426 &tracker,
1427 );
1428 let c2 = map_event(
1429 AgentEvent::Core(CoreEvent::ToolCompleted {
1430 name: "bash".into(),
1431 result: motosan_agent_tool::ToolResult::text("b"),
1432 }),
1433 &tracker,
1434 );
1435
1436 let id_s1 = match s1 {
1437 Some(UiEvent::ToolCallStarted { id, .. }) => id,
1438 other => panic!("{other:?}"),
1439 };
1440 let id_s2 = match s2 {
1441 Some(UiEvent::ToolCallStarted { id, .. }) => id,
1442 other => panic!("{other:?}"),
1443 };
1444 let id_c1 = match c1 {
1445 Some(UiEvent::ToolCallCompleted { id, .. }) => id,
1446 other => panic!("{other:?}"),
1447 };
1448 let id_c2 = match c2 {
1449 Some(UiEvent::ToolCallCompleted { id, .. }) => id,
1450 other => panic!("{other:?}"),
1451 };
1452
1453 assert_eq!(id_s1, id_c1);
1454 assert_eq!(id_s2, id_c2);
1455 assert_ne!(id_s1, id_s2);
1456 }
1457
1458 #[tokio::test]
1459 async fn concurrent_send_user_message_returns_an_error_instead_of_hanging() {
1460 let dir = tempfile::tempdir().unwrap();
1461 let mut cfg = Config::default();
1462 cfg.anthropic.api_key = Some("sk-unused".into());
1463 let app = AppBuilder::new()
1464 .with_config(cfg)
1465 .with_cwd(dir.path())
1466 .with_builtin_tools()
1467 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
1468 turn: AtomicUsize::new(0),
1469 }))
1470 .build()
1471 .await
1472 .expect("build");
1473
1474 let mut first = Box::pin(app.send_user_message("first".into()));
1475 let first_event = first.next().await;
1476 assert!(matches!(first_event, Some(UiEvent::AgentTurnStarted)));
1477
1478 let second_events: Vec<UiEvent> = app.send_user_message("second".into()).collect().await;
1479 assert_eq!(
1480 second_events.len(),
1481 1,
1482 "expected immediate single error event, got: {second_events:?}"
1483 );
1484 assert!(matches!(
1485 &second_events[0],
1486 UiEvent::Error(msg) if msg.contains("single-turn-per-App")
1487 ));
1488 }
1489
1490 #[test]
1491 fn progress_events_only_claim_an_id_when_exactly_one_tool_is_pending() {
1492 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
1493 assert_eq!(progress_event_id(&tracker), "tool_unknown");
1494
1495 let only = map_event(
1496 AgentEvent::Core(CoreEvent::ToolStarted {
1497 name: "bash".into(),
1498 }),
1499 &tracker,
1500 );
1501 let only_id = match only {
1502 Some(UiEvent::ToolCallStarted { id, .. }) => id,
1503 other => panic!("{other:?}"),
1504 };
1505 assert_eq!(progress_event_id(&tracker), only_id);
1506
1507 let _second = map_event(
1508 AgentEvent::Core(CoreEvent::ToolStarted {
1509 name: "read".into(),
1510 }),
1511 &tracker,
1512 );
1513 assert_eq!(progress_event_id(&tracker), "tool_unknown");
1514 }
1515
1516 #[tokio::test]
1517 async fn builder_rejects_builtin_and_custom_tools_together() {
1518 let mut cfg = Config::default();
1519 cfg.anthropic.api_key = Some("sk-unused".into());
1520 let dir = tempfile::tempdir().unwrap();
1521 let err = match AppBuilder::new()
1522 .with_config(cfg)
1523 .with_cwd(dir.path())
1524 .with_builtin_tools()
1525 .with_custom_tools_factory(|_| Vec::new())
1526 .build()
1527 .await
1528 {
1529 Ok(_) => panic!("must reject conflicting tool configuration"),
1530 Err(err) => err,
1531 };
1532
1533 assert!(format!("{err}").contains("mutually exclusive"));
1534 }
1535
1536 #[tokio::test]
1538 async fn two_turns_in_same_session_share_history() {
1539 #[derive(Default)]
1540 struct CounterLlm {
1541 turn: AtomicUsize,
1542 }
1543 #[async_trait]
1544 impl LlmClient for CounterLlm {
1545 async fn chat(
1546 &self,
1547 messages: &[Message],
1548 _tools: &[ToolDef],
1549 ) -> motosan_agent_loop::Result<ChatOutput> {
1550 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
1551 let answer = format!("turn-{turn}-saw-{}-messages", messages.len());
1552 Ok(ChatOutput::new(LlmResponse::Message(answer)))
1553 }
1554 }
1555
1556 let tmp = tempfile::tempdir().unwrap();
1557 let store = std::sync::Arc::new(motosan_agent_loop::FileSessionStore::new(
1558 tmp.path().to_path_buf(),
1559 ));
1560
1561 let app = AppBuilder::new()
1562 .with_settings(crate::settings::Settings::default())
1563 .with_auth(crate::auth::Auth::default())
1564 .with_cwd(tmp.path())
1565 .with_builtin_tools()
1566 .disable_context_discovery()
1567 .with_llm(std::sync::Arc::new(CounterLlm::default()))
1568 .with_session_store(store)
1569 .build_with_session(None)
1570 .await
1571 .expect("build");
1572
1573 let _events1: Vec<UiEvent> = app.send_user_message("hi".into()).collect().await;
1574 let events2: Vec<UiEvent> = app.send_user_message("again".into()).collect().await;
1575
1576 let saw_more_than_one = events2.iter().any(|e| {
1578 matches!(
1579 e,
1580 UiEvent::AgentMessageComplete(t) if t.contains("messages") && !t.contains("saw-1-")
1581 )
1582 });
1583 assert!(
1584 saw_more_than_one,
1585 "second turn should have seen history; events: {events2:?}"
1586 );
1587 }
1588}
1589
1590#[cfg(test)]
1591mod skills_builder_tests {
1592 use super::*;
1593 use crate::skills::types::{Skill, SkillSource};
1594 use std::path::PathBuf;
1595
1596 fn fixture() -> Skill {
1597 Skill {
1598 name: "x".into(),
1599 description: "d".into(),
1600 file_path: PathBuf::from("/x.md"),
1601 base_dir: PathBuf::from("/"),
1602 disable_model_invocation: false,
1603 source: SkillSource::Global,
1604 }
1605 }
1606
1607 #[test]
1608 fn with_skills_stores_skills() {
1609 let b = AppBuilder::new().with_skills(vec![fixture()]);
1610 assert_eq!(b.skills.len(), 1);
1611 assert_eq!(b.skills[0].name, "x");
1612 }
1613
1614 #[test]
1615 fn without_skills_clears() {
1616 let b = AppBuilder::new()
1617 .with_skills(vec![fixture()])
1618 .without_skills();
1619 assert!(b.skills.is_empty());
1620 }
1621}
1622
1623#[cfg(test)]
1624mod mcp_builder_tests {
1625 use super::*;
1626 use motosan_agent_tool::Tool;
1627
1628 struct FakeTool;
1630 impl Tool for FakeTool {
1631 fn def(&self) -> motosan_agent_tool::ToolDef {
1632 motosan_agent_tool::ToolDef {
1633 name: "fake__echo".into(),
1634 description: "test".into(),
1635 input_schema: serde_json::json!({"type": "object"}),
1636 }
1637 }
1638 fn call(
1639 &self,
1640 _args: serde_json::Value,
1641 _ctx: &motosan_agent_tool::ToolContext,
1642 ) -> std::pin::Pin<
1643 Box<dyn std::future::Future<Output = motosan_agent_tool::ToolResult> + Send + '_>,
1644 > {
1645 Box::pin(async { motosan_agent_tool::ToolResult::text("ok") })
1646 }
1647 }
1648
1649 #[test]
1650 fn with_extra_tools_stores_tools() {
1651 let tools: Vec<Arc<dyn Tool>> = vec![Arc::new(FakeTool)];
1652 let b = AppBuilder::new().with_extra_tools(tools);
1653 assert_eq!(b.extra_tools.len(), 1);
1654 }
1655}