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
23pub struct App {
24 session: Arc<AgentSession>,
30 config: Config,
31 cancel_token: SharedCancelToken,
32 progress_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<ToolProgressChunk>>>,
33 next_tool_id: Arc<Mutex<ToolCallTracker>>,
34 skills: Arc<Vec<crate::skills::Skill>>,
35 mcp_servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
36 pub(crate) session_cache: Arc<crate::permissions::SessionCache>,
37}
38
39impl App {
40 pub fn config(&self) -> &Config {
41 &self.config
42 }
43
44 pub fn cancel(&self) {
48 self.cancel_token.cancel();
49 }
50
51 pub fn permissions_cache(&self) -> Arc<crate::permissions::SessionCache> {
55 Arc::clone(&self.session_cache)
56 }
57
58 pub fn session_id(&self) -> &str {
61 self.session.session_id()
62 }
63
64 pub async fn session_history(
73 &self,
74 ) -> motosan_agent_loop::Result<Vec<motosan_agent_loop::Message>> {
75 self.session.history().await
76 }
77
78 pub async fn disconnect_mcp(&self) {
81 for (name, server) in &self.mcp_servers {
82 let _ =
83 tokio::time::timeout(std::time::Duration::from_secs(2), server.disconnect()).await;
84 tracing::debug!(target: "mcp", server = %name, "disconnected");
85 }
86 }
87
88 pub fn send_user_message(&self, text: String) -> impl Stream<Item = UiEvent> + Send + 'static {
89 let session = Arc::clone(&self.session);
90 let skills = Arc::clone(&self.skills);
91 let cancel_token = self.cancel_token.clone();
92 let tracker = Arc::clone(&self.next_tool_id);
93 let progress = Arc::clone(&self.progress_rx);
94
95 async_stream::stream! {
96 let mut progress_guard = match progress.try_lock() {
98 Ok(guard) => guard,
99 Err(_) => {
100 yield UiEvent::Error(
101 "another turn is already running; capo is single-turn-per-App".into(),
102 );
103 return;
104 }
105 };
106
107 let cancel = cancel_token.reset();
109
110 yield UiEvent::AgentTurnStarted;
111 yield UiEvent::AgentThinking;
112
113 let history = match session.history().await {
115 Ok(h) => h,
116 Err(err) => {
117 yield UiEvent::Error(format!("session.history failed: {err}"));
118 return;
119 }
120 };
121 let mut messages = history;
122 let text = crate::skills::expand::expand_skill_command(&text, &skills);
123 messages.push(motosan_agent_loop::Message::user(&text));
124
125 let handle = match session.start_turn(messages).await {
127 Ok(h) => h,
128 Err(err) => {
129 yield UiEvent::Error(format!("session.start_turn failed: {err}"));
130 return;
131 }
132 };
133 let previous_len = handle.previous_len;
134 let epoch = handle.epoch;
135 let ops_tx = handle.ops_tx.clone();
136 let mut agent_stream = handle.stream;
137
138 let interrupt_bridge = tokio::spawn(async move {
146 cancel.cancelled().await;
147 let _ = ops_tx.send(AgentOp::Interrupt).await;
148 });
149
150 let mut terminal_messages: Option<Vec<motosan_agent_loop::Message>> = None;
152 let mut terminal_result: Option<motosan_agent_loop::Result<motosan_agent_loop::AgentResult>> = None;
153
154 loop {
155 while let Ok(chunk) = progress_guard.try_recv() {
157 yield UiEvent::ToolCallProgress {
158 id: progress_event_id(&tracker),
159 chunk: ProgressChunk::from(chunk),
160 };
161 }
162
163 tokio::select! {
164 biased;
165 maybe_item = agent_stream.next() => {
166 match maybe_item {
167 Some(AgentStreamItem::Event(ev)) => {
168 if let Some(ui) = map_event(ev, &tracker) {
169 yield ui;
170 }
171 }
172 Some(AgentStreamItem::Terminal(term)) => {
173 terminal_result = Some(term.result);
174 terminal_messages = Some(term.messages);
175 break;
176 }
177 None => break,
178 }
179 }
180 Some(chunk) = progress_guard.recv() => {
181 yield UiEvent::ToolCallProgress {
182 id: progress_event_id(&tracker),
183 chunk: ProgressChunk::from(chunk),
184 };
185 }
186 }
187 }
188
189 interrupt_bridge.abort();
191
192 if let Some(msgs) = terminal_messages.as_ref() {
194 if let Err(err) = session.record_turn_outcome(epoch, previous_len, msgs).await {
195 yield UiEvent::Error(format!("session.record_turn_outcome: {err}"));
196 }
197 }
198
199 match terminal_result {
201 Some(Ok(_)) => {
202 let final_text = terminal_messages
203 .as_ref()
204 .and_then(|msgs| {
205 msgs.iter()
206 .rev()
207 .find(|m| m.role() == motosan_agent_loop::Role::Assistant)
208 .map(|m| m.text())
209 })
210 .unwrap_or_default();
211 if !final_text.is_empty() {
212 yield UiEvent::AgentMessageComplete(final_text);
213 }
214 while let Ok(chunk) = progress_guard.try_recv() {
216 yield UiEvent::ToolCallProgress {
217 id: progress_event_id(&tracker),
218 chunk: ProgressChunk::from(chunk),
219 };
220 }
221 yield UiEvent::AgentTurnComplete;
222 }
223 Some(Err(err)) => {
224 yield UiEvent::Error(format!("{err}"));
225 }
226 None => { }
227 }
228 }
229 }
230}
231
232#[derive(Debug, Default)]
233struct ToolCallTracker {
234 next_id: usize,
235 pending: VecDeque<(String, String)>,
236}
237
238impl ToolCallTracker {
239 fn start(&mut self, name: &str) -> String {
240 self.next_id += 1;
241 let id = format!("tool_{}", self.next_id);
242 self.pending.push_back((name.to_string(), id.clone()));
243 id
244 }
245
246 fn complete(&mut self, name: &str) -> String {
247 if let Some(pos) = self
248 .pending
249 .iter()
250 .position(|(pending_name, _)| pending_name == name)
251 {
252 if let Some((_, id)) = self.pending.remove(pos) {
253 return id;
254 }
255 }
256
257 self.next_id += 1;
258 format!("tool_{}", self.next_id)
259 }
260
261 fn progress_id(&self) -> Option<String> {
266 match self.pending.len() {
267 1 => self.pending.front().map(|(_, id)| id.clone()),
268 _ => None,
269 }
270 }
271}
272
273fn lock_tool_tracker(tracker: &Arc<Mutex<ToolCallTracker>>) -> MutexGuard<'_, ToolCallTracker> {
274 match tracker.lock() {
275 Ok(guard) => guard,
276 Err(poisoned) => poisoned.into_inner(),
277 }
278}
279
280fn progress_event_id(tracker: &Arc<Mutex<ToolCallTracker>>) -> String {
281 lock_tool_tracker(tracker)
282 .progress_id()
283 .unwrap_or_else(|| "tool_unknown".to_string())
284}
285
286fn anthropic_api_key_from<F>(auth: &crate::auth::Auth, env_lookup: F) -> Option<String>
287where
288 F: Fn(&str) -> Option<String>,
289{
290 env_lookup("ANTHROPIC_API_KEY")
291 .map(|key| key.trim().to_string())
292 .filter(|key| !key.is_empty())
293 .or_else(|| auth.api_key("anthropic").map(str::to_string))
294}
295
296fn map_event(ev: AgentEvent, tool_tracker: &Arc<Mutex<ToolCallTracker>>) -> Option<UiEvent> {
297 match ev {
298 AgentEvent::Core(CoreEvent::TextChunk(delta)) => Some(UiEvent::AgentTextDelta(delta)),
299 AgentEvent::Core(CoreEvent::ToolStarted { name }) => {
300 let id = lock_tool_tracker(tool_tracker).start(&name);
301 Some(UiEvent::ToolCallStarted {
302 id,
303 name,
304 args: serde_json::json!({}),
305 })
306 }
307 AgentEvent::Core(CoreEvent::ToolCompleted { name, result }) => {
308 let id = lock_tool_tracker(tool_tracker).complete(&name);
309 Some(UiEvent::ToolCallCompleted {
310 id,
311 result: UiToolResult {
312 is_error: result.is_error,
313 text: format!("{name}: {result:?}"),
314 },
315 })
316 }
317 _ => None,
318 }
319}
320
321type CustomToolsFactory = Box<dyn FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>>>;
322
323pub struct AppBuilder {
324 config: Option<Config>,
325 cwd: Option<PathBuf>,
326 permission_gate: Option<Arc<dyn PermissionGate>>,
327 install_builtin_tools: bool,
328 max_iterations: usize,
329 llm_override: Option<Arc<dyn LlmClient>>,
330 custom_tools_factory: Option<CustomToolsFactory>,
331 permissions_policy_path: Option<PathBuf>,
332 ui_tx: Option<mpsc::Sender<crate::events::UiEvent>>,
333 settings: Option<crate::settings::Settings>,
334 auth: Option<crate::auth::Auth>,
335 context_discovery_disabled: bool,
336 session_store: Option<Arc<dyn SessionStore>>,
338 resume_session_id: Option<crate::session::SessionId>,
339 autocompact_enabled: bool,
340 skills: Vec<crate::skills::Skill>,
342 extra_tools: Vec<Arc<dyn motosan_agent_tool::Tool>>,
344 mcp_servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
345}
346
347impl Default for AppBuilder {
348 fn default() -> Self {
349 Self {
350 config: None,
351 cwd: None,
352 permission_gate: None,
353 install_builtin_tools: false,
354 max_iterations: 20,
355 llm_override: None,
356 custom_tools_factory: None,
357 permissions_policy_path: None,
358 ui_tx: None,
359 settings: None,
360 auth: None,
361 context_discovery_disabled: false,
362 session_store: None,
363 resume_session_id: None,
364 autocompact_enabled: false,
365 skills: Vec::new(),
366 extra_tools: Vec::new(),
367 mcp_servers: Vec::new(),
368 }
369 }
370}
371
372impl AppBuilder {
373 pub fn new() -> Self {
374 Self::default()
375 }
376
377 pub fn with_config(mut self, cfg: Config) -> Self {
378 self.config = Some(cfg);
379 self
380 }
381
382 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
383 self.cwd = Some(cwd.into());
384 self
385 }
386
387 pub fn with_permission_gate(mut self, gate: Arc<dyn PermissionGate>) -> Self {
388 self.permission_gate = Some(gate);
389 self
390 }
391
392 pub fn with_builtin_tools(mut self) -> Self {
398 self.install_builtin_tools = true;
399 self
400 }
401
402 pub fn with_max_iterations(mut self, n: usize) -> Self {
403 self.max_iterations = n;
404 self
405 }
406
407 pub fn with_llm(mut self, llm: Arc<dyn LlmClient>) -> Self {
408 self.llm_override = Some(llm);
409 self
410 }
411
412 pub fn with_permissions_config(mut self, path: PathBuf) -> Self {
413 self.permissions_policy_path = Some(path);
414 self
415 }
416
417 pub fn with_ui_channel(mut self, tx: mpsc::Sender<UiEvent>) -> Self {
418 self.ui_tx = Some(tx);
419 self
420 }
421
422 pub fn with_settings(mut self, settings: crate::settings::Settings) -> Self {
424 self.settings = Some(settings);
425 self
426 }
427
428 pub fn with_auth(mut self, auth: crate::auth::Auth) -> Self {
430 self.auth = Some(auth);
431 self
432 }
433
434 pub fn disable_context_discovery(mut self) -> Self {
437 self.context_discovery_disabled = true;
438 self
439 }
440
441 pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
444 self.session_store = Some(store);
445 self
446 }
447
448 pub fn with_autocompact(mut self) -> Self {
453 self.autocompact_enabled = true;
454 self
455 }
456
457 pub fn with_skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
463 self.skills = skills;
464 self
465 }
466
467 pub fn without_skills(mut self) -> Self {
468 self.skills.clear();
469 self
470 }
471
472 pub fn with_extra_tools(mut self, tools: Vec<Arc<dyn motosan_agent_tool::Tool>>) -> Self {
476 self.extra_tools = tools;
477 self
478 }
479
480 pub fn with_mcp_servers(
483 mut self,
484 servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
485 ) -> Self {
486 self.mcp_servers = servers;
487 self
488 }
489
490 pub fn with_custom_tools_factory(
495 mut self,
496 factory: impl FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>> + 'static,
497 ) -> Self {
498 self.custom_tools_factory = Some(Box::new(factory));
499 self
500 }
501
502 pub async fn build_with_custom_tools(
506 self,
507 factory: impl FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>> + 'static,
508 ) -> Result<App> {
509 self.with_custom_tools_factory(factory).build().await
510 }
511
512 pub async fn build_with_session(
520 mut self,
521 resume: Option<crate::session::SessionId>,
522 ) -> Result<App> {
523 if let Some(id) = resume {
524 if self.session_store.is_none() {
525 return Err(AppError::Config(
526 "build_with_session(Some(id)) requires with_session_store(...)".into(),
527 ));
528 }
529 self.resume_session_id = Some(id);
530 }
531 self.build_internal().await
532 }
533
534 pub async fn build(self) -> Result<App> {
536 self.build_with_session(None).await
537 }
538
539 async fn build_internal(mut self) -> Result<App> {
540 let mcp_servers = std::mem::take(&mut self.mcp_servers);
541 let extra_tools = std::mem::take(&mut self.extra_tools);
542 let skills = self.skills.clone();
543 if self.install_builtin_tools && self.custom_tools_factory.is_some() {
544 return Err(AppError::Config(
545 "with_builtin_tools and with_custom_tools_factory are mutually exclusive".into(),
546 ));
547 }
548
549 let has_config = self.config.is_some();
553 let has_auth = self.auth.is_some();
554 let mut config = self.config.unwrap_or_default();
555 let settings = match self.settings {
556 Some(settings) => settings,
557 None => {
558 let mut settings = crate::settings::Settings::default();
559 settings.model.provider = config.model.provider.clone();
560 settings.model.name = config.model.name.clone();
561 settings.model.max_tokens = config.model.max_tokens;
562 settings
563 }
564 };
565 config.model.provider = settings.model.provider.clone();
566 config.model.name = settings.model.name.clone();
567 config.model.max_tokens = settings.model.max_tokens;
568 let mut auth = self.auth.unwrap_or_default();
569 if !has_auth {
570 if let Some(key) = config.anthropic.api_key.as_deref() {
571 auth.0.insert(
572 "anthropic".into(),
573 crate::auth::ProviderAuth::ApiKey {
574 key: key.to_string(),
575 },
576 );
577 }
578 }
579 let env_or_auth_key = anthropic_api_key_from(&auth, |name| std::env::var(name).ok());
580 if env_or_auth_key.is_some() || has_auth || !has_config {
581 config.anthropic.api_key = env_or_auth_key;
582 }
583 let cwd = self
584 .cwd
585 .or_else(|| std::env::current_dir().ok())
586 .unwrap_or_else(|| PathBuf::from("."));
587 let permission_gate = self.permission_gate.unwrap_or_else(|| {
588 if self.ui_tx.is_some() {
592 Arc::new(NoOpPermissionGate) as Arc<dyn PermissionGate>
593 } else {
594 tracing::warn!("no PermissionGate and no UI channel — tools run unchecked");
595 Arc::new(NoOpPermissionGate) as Arc<dyn PermissionGate>
596 }
597 });
598
599 let llm = if let Some(llm) = self.llm_override {
600 llm
601 } else {
602 build_llm_client(&settings, &auth)?
603 };
604
605 let (progress_tx, progress_rx) = mpsc::channel::<ToolProgressChunk>(64);
607 let tool_ctx = ToolCtx::new(&cwd, Arc::clone(&permission_gate), progress_tx);
608 let cancel_token = tool_ctx.cancel_token.clone();
609
610 let mut tools = if self.install_builtin_tools {
611 builtin_tools(tool_ctx.clone())
612 } else if let Some(factory) = self.custom_tools_factory {
613 factory(tool_ctx.clone())
614 } else {
615 Vec::new()
616 };
617 tools.extend(extra_tools);
618
619 let tool_names: Vec<String> = tools.iter().map(|t| t.def().name).collect();
620 let base_prompt = build_system_prompt(&tool_names, &skills);
621 let system_prompt = if self.context_discovery_disabled {
622 base_prompt
623 } else {
624 let agent_dir = crate::paths::agent_dir();
625 let context = crate::context_files::load_project_context_files(&cwd, &agent_dir);
626 crate::context_files::assemble_system_prompt(&base_prompt, &context, &cwd)
627 };
628 let motosan_tool_context = ToolContext::new("capo", "capo").with_cwd(&cwd);
629
630 let policy: Arc<crate::permissions::Policy> =
632 Arc::new(match self.permissions_policy_path.as_ref() {
633 Some(path) => crate::permissions::Policy::load_or_default(path)?,
634 None => crate::permissions::Policy::default(),
635 });
636 let session_cache = Arc::new(crate::permissions::SessionCache::new());
637
638 let mut engine_builder = Engine::builder()
639 .max_iterations(self.max_iterations)
640 .system_prompt(system_prompt)
641 .tool_context(motosan_tool_context);
642 for tool in tools {
643 engine_builder = engine_builder.tool(tool);
644 }
645 if let Some(ui_tx) = self.ui_tx {
646 let ext = crate::permissions::PermissionExtension::new(
647 Arc::clone(&policy),
648 Arc::clone(&session_cache),
649 cwd.clone(),
650 ui_tx,
651 );
652 engine_builder = engine_builder.extension(Box::new(ext));
653 }
654 if self.autocompact_enabled
656 && settings.session.compact_at_context_pct > 0.0
657 && settings.session.compact_at_context_pct < 1.0
658 {
659 let cfg = AutocompactConfig {
660 threshold: settings.session.compact_at_context_pct,
661 max_context_tokens: settings.session.max_context_tokens,
662 keep_turns: settings.session.keep_turns.max(1),
663 };
664 let ext = AutocompactExtension::new(cfg, Arc::clone(&llm));
665 engine_builder = engine_builder.extension(Box::new(ext));
666 }
667 let engine = engine_builder.build();
668
669 let session = match (self.resume_session_id, self.session_store) {
671 (Some(id), Some(store)) => {
672 let s =
673 AgentSession::resume(id.as_str(), Arc::clone(&store), engine, Arc::clone(&llm))
674 .await
675 .map_err(|err| AppError::Config(format!("resume failed: {err}")))?;
676 let entries = s
678 .entries()
679 .await
680 .map_err(|err| AppError::Config(format!("entries failed: {err}")))?;
681 crate::session::hydrate_read_files(&entries, &tool_ctx).await?;
682 s
683 }
684 (None, Some(store)) => {
685 let id = crate::session::SessionId::new();
686 AgentSession::new_with_store(id.into_string(), store, engine, Arc::clone(&llm))
687 }
688 (None, None) => AgentSession::new(engine, Arc::clone(&llm)),
689 (Some(_), None) => unreachable!("guarded in build_with_session"),
690 };
691
692 Ok(App {
693 session: Arc::new(session),
694 config,
695 cancel_token,
696 progress_rx: Arc::new(tokio::sync::Mutex::new(progress_rx)),
697 next_tool_id: Arc::new(Mutex::new(ToolCallTracker::default())),
698 skills: Arc::new(skills),
699 mcp_servers,
700 session_cache,
701 })
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use crate::config::{AnthropicConfig, ModelConfig};
709 use crate::events::UiEvent;
710 use async_trait::async_trait;
711 use motosan_agent_loop::{ChatOutput, LlmClient, LlmResponse, Message, ToolCallItem};
712 use motosan_agent_tool::ToolDef;
713 use std::sync::atomic::{AtomicUsize, Ordering};
714
715 #[tokio::test]
716 async fn builder_fails_without_api_key() {
717 let cfg = Config {
718 anthropic: AnthropicConfig {
719 api_key: None,
720 base_url: "https://api.anthropic.com".into(),
721 },
722 model: ModelConfig {
723 provider: "anthropic".into(),
724 name: "claude-sonnet-4-6".into(),
725 max_tokens: 4096,
726 },
727 };
728 let err = match AppBuilder::new()
729 .with_config(cfg)
730 .with_builtin_tools()
731 .build()
732 .await
733 {
734 Ok(_) => panic!("must fail without key"),
735 Err(err) => err,
736 };
737 assert!(format!("{err}").contains("ANTHROPIC_API_KEY"));
738 }
739
740 struct ToolOnlyLlm {
741 turn: AtomicUsize,
742 }
743
744 #[async_trait]
745 impl LlmClient for ToolOnlyLlm {
746 async fn chat(
747 &self,
748 _messages: &[Message],
749 _tools: &[ToolDef],
750 ) -> motosan_agent_loop::Result<ChatOutput> {
751 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
752 if turn == 0 {
753 Ok(ChatOutput::new(LlmResponse::ToolCalls(vec![
754 ToolCallItem {
755 id: "t1".into(),
756 name: "read".into(),
757 args: serde_json::json!({"path":"nope.txt"}),
758 },
759 ])))
760 } else {
761 Ok(ChatOutput::new(LlmResponse::Message(String::new())))
762 }
763 }
764 }
765
766 #[tokio::test]
767 async fn empty_final_message_is_not_emitted() {
768 let dir = tempfile::tempdir().unwrap();
769 let mut cfg = Config::default();
770 cfg.anthropic.api_key = Some("sk-unused".into());
771 let app = AppBuilder::new()
772 .with_config(cfg)
773 .with_cwd(dir.path())
774 .with_builtin_tools()
775 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
776 turn: AtomicUsize::new(0),
777 }))
778 .build()
779 .await
780 .expect("build");
781 let events: Vec<UiEvent> =
782 futures::StreamExt::collect(app.send_user_message("x".into())).await;
783 let empties = events
784 .iter()
785 .filter(|e| matches!(e, UiEvent::AgentMessageComplete(t) if t.is_empty()))
786 .count();
787 assert_eq!(
788 empties, 0,
789 "should not emit empty final message, got: {events:?}"
790 );
791 }
792
793 struct EchoLlm;
794
795 #[async_trait]
796 impl LlmClient for EchoLlm {
797 async fn chat(
798 &self,
799 _messages: &[Message],
800 _tools: &[ToolDef],
801 ) -> motosan_agent_loop::Result<ChatOutput> {
802 Ok(ChatOutput::new(LlmResponse::Message("ok".into())))
803 }
804 }
805
806 #[test]
807 fn anthropic_env_api_key_overrides_auth_json_key() {
808 let mut auth = crate::auth::Auth::default();
809 auth.0.insert(
810 "anthropic".into(),
811 crate::auth::ProviderAuth::ApiKey {
812 key: "sk-auth".into(),
813 },
814 );
815
816 let key = anthropic_api_key_from(&auth, |name| {
817 (name == "ANTHROPIC_API_KEY").then(|| " sk-env ".to_string())
818 });
819 assert_eq!(key.as_deref(), Some("sk-env"));
820 }
821
822 #[tokio::test]
823 async fn with_settings_overrides_deprecated_config_model() {
824 use crate::settings::Settings;
825
826 let mut config = Config::default();
827 config.model.name = "from-config".into();
828 config.anthropic.api_key = Some("sk-config".into());
829
830 let mut settings = Settings::default();
831 settings.model.name = "from-settings".into();
832
833 let tmp = tempfile::tempdir().unwrap();
834 let app = AppBuilder::new()
835 .with_config(config)
836 .with_settings(settings)
837 .with_cwd(tmp.path())
838 .disable_context_discovery()
839 .with_llm(Arc::new(EchoLlm))
840 .build()
841 .await
842 .expect("build");
843 assert_eq!(app.config().model.name, "from-settings");
844 assert_eq!(app.config().anthropic.api_key.as_deref(), Some("sk-config"));
845 }
846
847 #[tokio::test]
848 async fn with_settings_synthesises_legacy_config_for_build() {
849 use crate::auth::{Auth, ProviderAuth};
850 use crate::settings::Settings;
851
852 let mut settings = Settings::default();
853 settings.model.name = "claude-sonnet-4-6".into();
854
855 let mut auth = Auth::default();
856 auth.0.insert(
857 "anthropic".into(),
858 ProviderAuth::ApiKey {
859 key: "sk-test".into(),
860 },
861 );
862
863 let tmp = tempfile::tempdir().unwrap();
864 let app = AppBuilder::new()
865 .with_settings(settings)
866 .with_auth(auth)
867 .with_cwd(tmp.path())
868 .with_builtin_tools()
869 .disable_context_discovery()
870 .with_llm(Arc::new(EchoLlm))
871 .build()
872 .await
873 .expect("build");
874 let _ = app;
875 }
876
877 #[tokio::test]
878 async fn cancel_before_turn_does_not_poison_future_turns() {
879 let dir = tempfile::tempdir().unwrap();
880 let mut cfg = Config::default();
881 cfg.anthropic.api_key = Some("sk-unused".into());
882 let app = AppBuilder::new()
883 .with_config(cfg)
884 .with_cwd(dir.path())
885 .with_builtin_tools()
886 .with_llm(std::sync::Arc::new(EchoLlm))
887 .build()
888 .await
889 .expect("build");
890
891 app.cancel();
892 let events: Vec<UiEvent> = app.send_user_message("x".into()).collect().await;
893
894 assert!(
895 events
896 .iter()
897 .any(|e| matches!(e, UiEvent::AgentMessageComplete(text) if text == "ok")),
898 "turn should use a fresh cancellation token: {events:?}"
899 );
900 }
901
902 #[test]
903 fn map_event_matches_started_and_completed_ids_by_tool_name() {
904 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
905
906 let started_bash = map_event(
907 AgentEvent::Core(CoreEvent::ToolStarted {
908 name: "bash".into(),
909 }),
910 &tracker,
911 );
912 let started_read = map_event(
913 AgentEvent::Core(CoreEvent::ToolStarted {
914 name: "read".into(),
915 }),
916 &tracker,
917 );
918 let completed_bash = map_event(
919 AgentEvent::Core(CoreEvent::ToolCompleted {
920 name: "bash".into(),
921 result: motosan_agent_tool::ToolResult::text("ok"),
922 }),
923 &tracker,
924 );
925 let completed_read = map_event(
926 AgentEvent::Core(CoreEvent::ToolCompleted {
927 name: "read".into(),
928 result: motosan_agent_tool::ToolResult::text("ok"),
929 }),
930 &tracker,
931 );
932
933 assert!(matches!(
934 started_bash,
935 Some(UiEvent::ToolCallStarted { ref id, ref name, .. }) if id == "tool_1" && name == "bash"
936 ));
937 assert!(matches!(
938 started_read,
939 Some(UiEvent::ToolCallStarted { ref id, ref name, .. }) if id == "tool_2" && name == "read"
940 ));
941 assert!(matches!(
942 completed_bash,
943 Some(UiEvent::ToolCallCompleted { ref id, .. }) if id == "tool_1"
944 ));
945 assert!(matches!(
946 completed_read,
947 Some(UiEvent::ToolCallCompleted { ref id, .. }) if id == "tool_2"
948 ));
949 }
950
951 #[test]
952 fn tool_tracker_handles_two_concurrent_invocations_of_same_tool() {
953 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
954 let s1 = map_event(
955 AgentEvent::Core(CoreEvent::ToolStarted {
956 name: "bash".into(),
957 }),
958 &tracker,
959 );
960 let s2 = map_event(
961 AgentEvent::Core(CoreEvent::ToolStarted {
962 name: "bash".into(),
963 }),
964 &tracker,
965 );
966 let c1 = map_event(
967 AgentEvent::Core(CoreEvent::ToolCompleted {
968 name: "bash".into(),
969 result: motosan_agent_tool::ToolResult::text("a"),
970 }),
971 &tracker,
972 );
973 let c2 = map_event(
974 AgentEvent::Core(CoreEvent::ToolCompleted {
975 name: "bash".into(),
976 result: motosan_agent_tool::ToolResult::text("b"),
977 }),
978 &tracker,
979 );
980
981 let id_s1 = match s1 {
982 Some(UiEvent::ToolCallStarted { id, .. }) => id,
983 other => panic!("{other:?}"),
984 };
985 let id_s2 = match s2 {
986 Some(UiEvent::ToolCallStarted { id, .. }) => id,
987 other => panic!("{other:?}"),
988 };
989 let id_c1 = match c1 {
990 Some(UiEvent::ToolCallCompleted { id, .. }) => id,
991 other => panic!("{other:?}"),
992 };
993 let id_c2 = match c2 {
994 Some(UiEvent::ToolCallCompleted { id, .. }) => id,
995 other => panic!("{other:?}"),
996 };
997
998 assert_eq!(id_s1, id_c1);
999 assert_eq!(id_s2, id_c2);
1000 assert_ne!(id_s1, id_s2);
1001 }
1002
1003 #[tokio::test]
1004 async fn concurrent_send_user_message_returns_an_error_instead_of_hanging() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let mut cfg = Config::default();
1007 cfg.anthropic.api_key = Some("sk-unused".into());
1008 let app = AppBuilder::new()
1009 .with_config(cfg)
1010 .with_cwd(dir.path())
1011 .with_builtin_tools()
1012 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
1013 turn: AtomicUsize::new(0),
1014 }))
1015 .build()
1016 .await
1017 .expect("build");
1018
1019 let mut first = Box::pin(app.send_user_message("first".into()));
1020 let first_event = first.next().await;
1021 assert!(matches!(first_event, Some(UiEvent::AgentTurnStarted)));
1022
1023 let second_events: Vec<UiEvent> = app.send_user_message("second".into()).collect().await;
1024 assert_eq!(
1025 second_events.len(),
1026 1,
1027 "expected immediate single error event, got: {second_events:?}"
1028 );
1029 assert!(matches!(
1030 &second_events[0],
1031 UiEvent::Error(msg) if msg.contains("single-turn-per-App")
1032 ));
1033 }
1034
1035 #[test]
1036 fn progress_events_only_claim_an_id_when_exactly_one_tool_is_pending() {
1037 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
1038 assert_eq!(progress_event_id(&tracker), "tool_unknown");
1039
1040 let only = map_event(
1041 AgentEvent::Core(CoreEvent::ToolStarted {
1042 name: "bash".into(),
1043 }),
1044 &tracker,
1045 );
1046 let only_id = match only {
1047 Some(UiEvent::ToolCallStarted { id, .. }) => id,
1048 other => panic!("{other:?}"),
1049 };
1050 assert_eq!(progress_event_id(&tracker), only_id);
1051
1052 let _second = map_event(
1053 AgentEvent::Core(CoreEvent::ToolStarted {
1054 name: "read".into(),
1055 }),
1056 &tracker,
1057 );
1058 assert_eq!(progress_event_id(&tracker), "tool_unknown");
1059 }
1060
1061 #[tokio::test]
1062 async fn builder_rejects_builtin_and_custom_tools_together() {
1063 let mut cfg = Config::default();
1064 cfg.anthropic.api_key = Some("sk-unused".into());
1065 let dir = tempfile::tempdir().unwrap();
1066 let err = match AppBuilder::new()
1067 .with_config(cfg)
1068 .with_cwd(dir.path())
1069 .with_builtin_tools()
1070 .with_custom_tools_factory(|_| Vec::new())
1071 .build()
1072 .await
1073 {
1074 Ok(_) => panic!("must reject conflicting tool configuration"),
1075 Err(err) => err,
1076 };
1077
1078 assert!(format!("{err}").contains("mutually exclusive"));
1079 }
1080
1081 #[tokio::test]
1083 async fn two_turns_in_same_session_share_history() {
1084 #[derive(Default)]
1085 struct CounterLlm {
1086 turn: AtomicUsize,
1087 }
1088 #[async_trait]
1089 impl LlmClient for CounterLlm {
1090 async fn chat(
1091 &self,
1092 messages: &[Message],
1093 _tools: &[ToolDef],
1094 ) -> motosan_agent_loop::Result<ChatOutput> {
1095 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
1096 let answer = format!("turn-{turn}-saw-{}-messages", messages.len());
1097 Ok(ChatOutput::new(LlmResponse::Message(answer)))
1098 }
1099 }
1100
1101 let tmp = tempfile::tempdir().unwrap();
1102 let store = std::sync::Arc::new(motosan_agent_loop::FileSessionStore::new(
1103 tmp.path().to_path_buf(),
1104 ));
1105
1106 let app = AppBuilder::new()
1107 .with_settings(crate::settings::Settings::default())
1108 .with_auth(crate::auth::Auth::default())
1109 .with_cwd(tmp.path())
1110 .with_builtin_tools()
1111 .disable_context_discovery()
1112 .with_llm(std::sync::Arc::new(CounterLlm::default()))
1113 .with_session_store(store)
1114 .build_with_session(None)
1115 .await
1116 .expect("build");
1117
1118 let _events1: Vec<UiEvent> = app.send_user_message("hi".into()).collect().await;
1119 let events2: Vec<UiEvent> = app.send_user_message("again".into()).collect().await;
1120
1121 let saw_more_than_one = events2.iter().any(|e| {
1123 matches!(
1124 e,
1125 UiEvent::AgentMessageComplete(t) if t.contains("messages") && !t.contains("saw-1-")
1126 )
1127 });
1128 assert!(
1129 saw_more_than_one,
1130 "second turn should have seen history; events: {events2:?}"
1131 );
1132 }
1133}
1134
1135#[cfg(test)]
1136mod skills_builder_tests {
1137 use super::*;
1138 use crate::skills::types::{Skill, SkillSource};
1139 use std::path::PathBuf;
1140
1141 fn fixture() -> Skill {
1142 Skill {
1143 name: "x".into(),
1144 description: "d".into(),
1145 file_path: PathBuf::from("/x.md"),
1146 base_dir: PathBuf::from("/"),
1147 disable_model_invocation: false,
1148 source: SkillSource::Global,
1149 }
1150 }
1151
1152 #[test]
1153 fn with_skills_stores_skills() {
1154 let b = AppBuilder::new().with_skills(vec![fixture()]);
1155 assert_eq!(b.skills.len(), 1);
1156 assert_eq!(b.skills[0].name, "x");
1157 }
1158
1159 #[test]
1160 fn without_skills_clears() {
1161 let b = AppBuilder::new()
1162 .with_skills(vec![fixture()])
1163 .without_skills();
1164 assert!(b.skills.is_empty());
1165 }
1166}
1167
1168#[cfg(test)]
1169mod mcp_builder_tests {
1170 use super::*;
1171 use motosan_agent_tool::Tool;
1172
1173 struct FakeTool;
1175 impl Tool for FakeTool {
1176 fn def(&self) -> motosan_agent_tool::ToolDef {
1177 motosan_agent_tool::ToolDef {
1178 name: "fake__echo".into(),
1179 description: "test".into(),
1180 input_schema: serde_json::json!({"type": "object"}),
1181 }
1182 }
1183 fn call(
1184 &self,
1185 _args: serde_json::Value,
1186 _ctx: &motosan_agent_tool::ToolContext,
1187 ) -> std::pin::Pin<
1188 Box<dyn std::future::Future<Output = motosan_agent_tool::ToolResult> + Send + '_>,
1189 > {
1190 Box::pin(async { motosan_agent_tool::ToolResult::text("ok") })
1191 }
1192 }
1193
1194 #[test]
1195 fn with_extra_tools_stores_tools() {
1196 let tools: Vec<Arc<dyn Tool>> = vec![Arc::new(FakeTool)];
1197 let b = AppBuilder::new().with_extra_tools(tools);
1198 assert_eq!(b.extra_tools.len(), 1);
1199 }
1200}