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, AutocompactEvent,
10 AutocompactExtension, CoreEvent, Engine, ExtensionEvent, 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, PromptStrategy};
21use crate::tools::{builtin_tools, SharedCancelToken, ToolCtx, ToolProgressChunk};
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub struct TurnStatsAccum {
28 pub cumulative_input: u64,
29 pub cumulative_output: u64,
30 pub turn_count: u64,
31}
32
33impl TurnStatsAccum {
34 pub fn add(&mut self, usage: motosan_agent_loop::TokenUsage) {
38 self.cumulative_input = self.cumulative_input.saturating_add(usage.input_tokens);
39 self.cumulative_output = self.cumulative_output.saturating_add(usage.output_tokens);
40 self.turn_count = self.turn_count.saturating_add(1);
41 }
42}
43
44pub(crate) fn extract_thinking_events(messages: &[motosan_agent_loop::Message]) -> Vec<UiEvent> {
52 let mut events = Vec::new();
53 for msg in messages {
54 if let motosan_agent_loop::Message::Assistant { content, .. } = msg {
55 for part in content {
56 if let motosan_agent_loop::AssistantContent::Reasoning { text, .. } = part {
57 events.push(UiEvent::ThinkingComplete { text: text.clone() });
58 }
59 }
60 }
61 }
62 events
63}
64
65pub(crate) fn extract_new_thinking_events(
70 messages: &[motosan_agent_loop::Message],
71 previous_len: usize,
72) -> Vec<UiEvent> {
73 extract_thinking_events(messages.get(previous_len..).unwrap_or(&[]))
74}
75
76#[derive(Debug, Clone)]
78pub(crate) enum SessionMode {
79 New,
81 Resume(String),
83}
84
85struct SharedLlm {
90 client: Arc<dyn LlmClient>,
91}
92
93impl SharedLlm {
94 fn new(client: Arc<dyn LlmClient>) -> Self {
95 Self { client }
96 }
97
98 fn client(&self) -> Arc<dyn LlmClient> {
99 Arc::clone(&self.client)
100 }
101}
102
103pub(crate) struct SessionFactory {
104 cwd: PathBuf,
105 settings: Arc<Mutex<crate::settings::Settings>>,
106 auth: crate::auth::Auth,
107 policy: Arc<crate::permissions::Policy>,
108 session_cache: Arc<crate::permissions::SessionCache>,
109 ui_tx: Option<mpsc::Sender<UiEvent>>,
110 headless_permissions: bool,
111 permission_gate: Arc<dyn PermissionGate>,
112 permission_strategy_handle: Option<Arc<tokio::sync::RwLock<PromptStrategy>>>,
113 progress_tx: mpsc::Sender<ToolProgressChunk>,
116 skills: Arc<Vec<crate::skills::Skill>>,
117 install_builtin_tools: bool,
118 extra_tools: Vec<Arc<dyn motosan_agent_tool::Tool>>,
119 max_iterations: usize,
120 context_discovery_disabled: bool,
121 autocompact_enabled: bool,
122 session_store: Option<Arc<dyn SessionStore>>,
123 llm_override: Option<Arc<dyn LlmClient>>,
124 current_model: Arc<Mutex<Option<crate::model::ModelId>>>,
125 cancel_token: SharedCancelToken,
126}
127
128impl SessionFactory {
129 fn settings(&self) -> crate::settings::Settings {
130 match self.settings.lock() {
131 Ok(guard) => guard.clone(),
132 Err(poisoned) => poisoned.into_inner().clone(),
133 }
134 }
135
136 fn store_settings(&self, settings: crate::settings::Settings) {
137 match self.settings.lock() {
138 Ok(mut guard) => *guard = settings,
139 Err(poisoned) => *poisoned.into_inner() = settings,
140 }
141 }
142
143 fn current_model(&self) -> Option<crate::model::ModelId> {
144 match self.current_model.lock() {
145 Ok(guard) => guard.clone(),
146 Err(poisoned) => poisoned.into_inner().clone(),
147 }
148 }
149
150 fn set_current_model(&self, model: crate::model::ModelId) {
151 match self.current_model.lock() {
152 Ok(mut guard) => *guard = Some(model),
153 Err(poisoned) => *poisoned.into_inner() = Some(model),
154 }
155 }
156
157 fn clear_current_model(&self) {
158 match self.current_model.lock() {
159 Ok(mut guard) => *guard = None,
160 Err(poisoned) => *poisoned.into_inner() = None,
161 }
162 }
163
164 async fn build(
171 &self,
172 mode: SessionMode,
173 model_override: Option<&crate::model::ModelId>,
174 settings_override: Option<&crate::settings::Settings>,
175 ) -> Result<(AgentSession, Arc<dyn LlmClient>)> {
176 let effective_model = model_override.cloned().or_else(|| {
180 if settings_override.is_some() {
181 None
182 } else {
183 self.current_model()
184 }
185 });
186 let mut settings = settings_override
187 .cloned()
188 .unwrap_or_else(|| self.settings());
189 if let Some(m) = &effective_model {
190 settings.model.name = m.as_str().to_string();
191 }
192
193 let llm = if effective_model.is_none() {
194 self.llm_override.as_ref().map_or_else(
195 || build_llm_client(&settings, &self.auth),
196 |llm| Ok(Arc::clone(llm)),
197 )?
198 } else {
199 build_llm_client(&settings, &self.auth)?
200 };
201
202 let tool_ctx = ToolCtx::new_with_cancel_token(
205 &self.cwd,
206 Arc::clone(&self.permission_gate),
207 self.progress_tx.clone(),
208 self.cancel_token.clone(),
209 );
210 let mut tools = if self.install_builtin_tools {
211 builtin_tools(tool_ctx.clone())
212 } else {
213 Vec::new()
214 };
215 tools.extend(self.extra_tools.iter().cloned());
216
217 let tool_names: Vec<String> = tools.iter().map(|t| t.def().name).collect();
218 let base_prompt = build_system_prompt(&tool_names, &self.skills);
219 let system_prompt = if self.context_discovery_disabled {
220 base_prompt
221 } else {
222 let agent_dir = crate::paths::agent_dir();
223 let context = crate::context_files::load_project_context_files(&self.cwd, &agent_dir);
224 crate::context_files::assemble_system_prompt(&base_prompt, &context, &self.cwd)
225 };
226 let motosan_tool_context = ToolContext::new("capo", "capo").with_cwd(&self.cwd);
227
228 let mut engine_builder = Engine::builder()
229 .max_iterations(self.max_iterations)
230 .system_prompt(system_prompt)
231 .tool_context(motosan_tool_context);
232 for tool in tools {
233 engine_builder = engine_builder.tool(tool);
234 }
235 if let Some(ui_tx) = &self.ui_tx {
236 let ext = if let Some(handle) = &self.permission_strategy_handle {
237 crate::permissions::PermissionExtension::with_strategy_handle(
238 Arc::clone(&self.policy),
239 Arc::clone(&self.session_cache),
240 self.cwd.clone(),
241 Some(ui_tx.clone()),
242 Arc::clone(handle),
243 )
244 } else {
245 crate::permissions::PermissionExtension::new(
246 Arc::clone(&self.policy),
247 Arc::clone(&self.session_cache),
248 self.cwd.clone(),
249 ui_tx.clone(),
250 )
251 };
252 engine_builder = engine_builder.extension(Box::new(ext));
253 } else if self.headless_permissions {
254 let ext = if let Some(handle) = &self.permission_strategy_handle {
255 crate::permissions::PermissionExtension::with_strategy_handle(
256 Arc::clone(&self.policy),
257 Arc::clone(&self.session_cache),
258 self.cwd.clone(),
259 None,
260 Arc::clone(handle),
261 )
262 } else {
263 crate::permissions::PermissionExtension::headless(
264 Arc::clone(&self.policy),
265 Arc::clone(&self.session_cache),
266 self.cwd.clone(),
267 )
268 };
269 engine_builder = engine_builder.extension(Box::new(ext));
270 }
271 if self.autocompact_enabled
272 && settings.session.compact_at_context_pct > 0.0
273 && settings.session.compact_at_context_pct < 1.0
274 {
275 let cfg = AutocompactConfig {
276 threshold: settings.session.compact_at_context_pct,
277 max_context_tokens: settings.session.max_context_tokens,
278 keep_turns: settings.session.keep_turns.max(1),
279 };
280 engine_builder = engine_builder
281 .extension(Box::new(AutocompactExtension::new(cfg, Arc::clone(&llm))));
282 }
283 let engine = engine_builder.build();
284
285 let session = match (&mode, &self.session_store) {
286 (SessionMode::Resume(id), Some(store)) => {
287 let s = AgentSession::resume(id, Arc::clone(store), engine, Arc::clone(&llm))
288 .await
289 .map_err(|e| AppError::Config(format!("resume failed: {e}")))?;
290 let entries = s
291 .entries()
292 .await
293 .map_err(|e| AppError::Config(format!("entries failed: {e}")))?;
294 crate::session::hydrate_read_files(&entries, &tool_ctx).await?;
295 s
296 }
297 (SessionMode::Resume(_), None) => {
298 return Err(AppError::Config("resume requires a session store".into()));
299 }
300 (SessionMode::New, Some(store)) => {
301 let id = crate::session::SessionId::new();
302 AgentSession::new_with_store(
303 id.into_string(),
304 Arc::clone(store),
305 engine,
306 Arc::clone(&llm),
307 )
308 }
309 (SessionMode::New, None) => AgentSession::new(engine, Arc::clone(&llm)),
310 };
311
312 Ok((session, llm))
313 }
314}
315
316pub struct App {
317 session: arc_swap::ArcSwap<AgentSession>,
318 llm: arc_swap::ArcSwap<SharedLlm>,
319 factory: SessionFactory,
320 config: Config,
321 cancel_token: SharedCancelToken,
322 progress_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<ToolProgressChunk>>>,
323 next_tool_id: Arc<Mutex<ToolCallTracker>>,
324 skills: Arc<Vec<crate::skills::Skill>>,
325 mcp_servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
326 pub(crate) extension_registry: Arc<crate::extensions::ExtensionRegistry>,
328 pub(crate) token_tally: Arc<tokio::sync::Mutex<TurnStatsAccum>>,
333 extension_diagnostics: Arc<Vec<crate::extensions::ExtensionDiagnostic>>,
334 pub(crate) session_cache: Arc<crate::permissions::SessionCache>,
335 pub(crate) permission_mode: Arc<tokio::sync::RwLock<crate::permissions::PermissionMode>>,
338 pub(crate) permission_strategy_handle: Option<Arc<tokio::sync::RwLock<PromptStrategy>>>,
341 pub(crate) ui_tx_owned: Option<mpsc::Sender<UiEvent>>,
344}
345
346impl App {
347 pub fn config(&self) -> &Config {
348 &self.config
349 }
350
351 pub fn cancel(&self) {
355 self.cancel_token.cancel();
356 }
357
358 pub fn permissions_cache(&self) -> Arc<crate::permissions::SessionCache> {
362 Arc::clone(&self.session_cache)
363 }
364
365 pub fn extension_registry(&self) -> Arc<crate::extensions::ExtensionRegistry> {
368 Arc::clone(&self.extension_registry)
369 }
370
371 pub fn settings(&self) -> crate::settings::Settings {
373 let mut settings = self.factory.settings();
374 if let Some(model) = self.factory.current_model() {
375 settings.model.name = model.to_string();
376 }
377 settings
378 }
379
380 pub fn token_tally(&self) -> Arc<tokio::sync::Mutex<TurnStatsAccum>> {
382 Arc::clone(&self.token_tally)
383 }
384
385 pub async fn set_permission_mode(&self, mode: crate::permissions::PermissionMode) {
390 let new_strategy = match mode {
391 crate::permissions::PermissionMode::Bypass => PromptStrategy::AllowAll,
392 crate::permissions::PermissionMode::AcceptEdits => PromptStrategy::AcceptEdits,
393 crate::permissions::PermissionMode::Prompt if self.ui_tx_owned.is_none() => {
394 PromptStrategy::HeadlessDeny
395 }
396 crate::permissions::PermissionMode::Prompt => PromptStrategy::Prompt,
397 };
398
399 if let Some(handle) = &self.permission_strategy_handle {
400 *handle.write().await = new_strategy;
401 }
402
403 *self.permission_mode.write().await = mode;
404
405 if let Some(ui_tx) = &self.ui_tx_owned {
406 let _ = ui_tx.send(UiEvent::PermissionModeChanged { mode }).await;
407 }
408 }
409
410 pub async fn emit_settings_snapshot(&self) {
414 if let Some(ui_tx) = &self.ui_tx_owned {
415 let _ = ui_tx
416 .send(UiEvent::SettingsSnapshot {
417 settings: self.settings(),
418 })
419 .await;
420 }
421 }
422
423 pub async fn permission_mode(&self) -> crate::permissions::PermissionMode {
425 *self.permission_mode.read().await
426 }
427
428 pub fn extension_diagnostics(&self) -> Arc<Vec<crate::extensions::ExtensionDiagnostic>> {
430 Arc::clone(&self.extension_diagnostics)
431 }
432
433 pub fn session_id(&self) -> String {
436 self.session.load().session_id().to_string()
437 }
438
439 pub async fn session_history(
448 &self,
449 ) -> motosan_agent_loop::Result<Vec<motosan_agent_loop::Message>> {
450 self.session.load_full().history().await
451 }
452
453 pub async fn compact(&self) -> Result<Option<motosan_agent_loop::CompactionResult>> {
459 use motosan_agent_loop::ThresholdStrategy;
460 let strategy = ThresholdStrategy {
464 threshold: 0.0,
465 keep_turns: 1,
466 ..ThresholdStrategy::default()
467 };
468 let llm = self.llm.load_full().client();
469 self.session
470 .load_full()
471 .maybe_compact(&strategy, llm)
472 .await
473 .map_err(|e| AppError::Config(format!("compaction failed: {e}")))
474 }
475
476 pub async fn new_session(&self) -> Result<()> {
480 self.fire_session_before_switch("new", None).await?;
481 let (session, llm) = self.factory.build(SessionMode::New, None, None).await?;
482 self.session.store(Arc::new(session));
483 self.llm.store(Arc::new(SharedLlm::new(llm)));
484 self.reset_token_tally().await;
485 Ok(())
486 }
487
488 pub async fn load_session(&self, id: &str) -> Result<()> {
494 self.fire_session_before_switch("load", Some(id)).await?;
495 self.load_session_without_hook(id).await
496 }
497
498 async fn load_session_without_hook(&self, id: &str) -> Result<()> {
499 let (session, llm) = self
500 .factory
501 .build(SessionMode::Resume(id.to_string()), None, None)
502 .await?;
503 self.session.store(Arc::new(session));
504 self.llm.store(Arc::new(SharedLlm::new(llm)));
505 self.reset_token_tally().await;
506 Ok(())
507 }
508
509 async fn reset_token_tally(&self) {
510 let mut tally = self.token_tally.lock().await;
511 *tally = TurnStatsAccum::default();
512 }
513
514 pub async fn clone_session(&self) -> Result<String> {
523 self.fire_session_before_switch("clone", None).await?;
524 let Some(store) = self.factory.session_store.as_ref() else {
525 return Err(AppError::Config("clone requires a session store".into()));
526 };
527 let source_id = self.session.load().session_id().to_string();
528 let new_id = crate::session::SessionId::new().into_string();
529 let catalog = motosan_agent_loop::SessionCatalog::new(Arc::clone(store));
530 catalog
531 .fork(&source_id, &new_id)
532 .await
533 .map_err(|e| AppError::Config(format!("clone failed: {e}")))?;
534 self.load_session_without_hook(&new_id).await?;
535 Ok(new_id)
536 }
537
538 pub async fn switch_model(&self, model: &crate::model::ModelId) -> Result<()> {
553 self.fire_session_before_switch("model_switch", None)
554 .await?;
555 let current_id = self.session.load().session_id().to_string();
556 let (session, llm) = self
557 .factory
558 .build(SessionMode::Resume(current_id), Some(model), None)
559 .await?;
560 self.factory.set_current_model(model.clone());
561 self.session.store(Arc::new(session));
562 self.llm.store(Arc::new(SharedLlm::new(llm)));
563 Ok(())
564 }
565
566 pub async fn reload_settings(&self, new_settings: crate::settings::Settings) -> Result<()> {
582 let current_id = self.session.load().session_id().to_string();
586 let (session, llm) = self
587 .factory
588 .build(SessionMode::Resume(current_id), None, Some(&new_settings))
589 .await?;
590 self.factory.store_settings(new_settings);
591 self.factory.clear_current_model();
592 self.session.store(Arc::new(session));
593 self.llm.store(Arc::new(SharedLlm::new(llm)));
594
595 self.reset_token_tally().await;
598
599 Ok(())
600 }
601
602 pub async fn disconnect_mcp(&self) {
605 for (name, server) in &self.mcp_servers {
606 let _ =
607 tokio::time::timeout(std::time::Duration::from_secs(2), server.disconnect()).await;
608 tracing::debug!(target: "mcp", server = %name, "disconnected");
609 }
610 }
611
612 fn run_turn(
613 &self,
614 msg: crate::user_message::UserMessage,
615 fork_from: Option<motosan_agent_loop::EntryId>,
616 ) -> impl Stream<Item = UiEvent> + Send + 'static {
617 let session = self.session.load_full();
618 let skills = Arc::clone(&self.skills);
619 let cancel_token = self.cancel_token.clone();
620 let tracker = Arc::clone(&self.next_tool_id);
621 let progress = Arc::clone(&self.progress_rx);
622 let token_tally = Arc::clone(&self.token_tally);
623 let settings_model_name = self
624 .factory
625 .current_model()
626 .map(|model| model.to_string())
627 .unwrap_or_else(|| self.factory.settings().model.name);
628
629 async_stream::stream! {
630 let new_user = {
634 let expanded_text = crate::skills::expand::expand_skill_command(&msg.text, &skills);
638 let expanded_msg = crate::user_message::UserMessage {
639 text: expanded_text,
640 attachments: msg.attachments.clone(),
641 };
642 match crate::user_message::prepare_user_message(&expanded_msg) {
643 Ok(m) => m,
644 Err(err) => {
645 yield UiEvent::AttachmentError {
646 kind: err.kind(),
647 message: err.to_string(),
648 };
649 return;
650 }
651 }
652 };
653
654 let mut progress_guard = match progress.try_lock() {
656 Ok(guard) => guard,
657 Err(_) => {
658 yield UiEvent::Error(
659 "another turn is already running; capo is single-turn-per-App".into(),
660 );
661 return;
662 }
663 };
664
665 let cancel = cancel_token.reset();
667
668 yield UiEvent::AgentTurnStarted;
669 yield UiEvent::AgentThinking;
670
671 let handle = match fork_from {
673 None => {
674 let history = match session.history().await {
676 Ok(h) => h,
677 Err(err) => {
678 yield UiEvent::Error(format!("session.history failed: {err}"));
679 return;
680 }
681 };
682 let mut messages = history;
683 messages.push(new_user);
684 match session.start_turn(messages).await {
685 Ok(h) => h,
686 Err(err) => {
687 yield UiEvent::Error(format!("session.start_turn failed: {err}"));
688 return;
689 }
690 }
691 }
692 Some(from) => {
693 match session.fork_turn(from, vec![new_user]).await {
695 Ok(h) => h,
696 Err(err) => {
697 yield UiEvent::Error(format!("session.fork_turn failed: {err}"));
698 return;
699 }
700 }
701 }
702 };
703 let previous_len = handle.previous_len;
704 let epoch = handle.epoch;
705 let branch_parent = handle.branch_parent;
706 let ops_tx = handle.ops_tx.clone();
707 let mut agent_stream = handle.stream;
708
709 let interrupt_bridge = tokio::spawn(async move {
717 cancel.cancelled().await;
718 let _ = ops_tx.send(AgentOp::Interrupt).await;
719 });
720
721 let mut terminal_messages: Option<Vec<motosan_agent_loop::Message>> = None;
723 let mut terminal_result: Option<motosan_agent_loop::Result<motosan_agent_loop::AgentResult>> = None;
724 let mut streamed_thinking_seen = false;
729
730 loop {
731 while let Ok(chunk) = progress_guard.try_recv() {
733 yield UiEvent::ToolCallProgress {
734 id: progress_event_id(&tracker),
735 chunk: ProgressChunk::from(chunk),
736 };
737 }
738
739 tokio::select! {
740 biased;
741 maybe_item = agent_stream.next() => {
742 match maybe_item {
743 Some(AgentStreamItem::Event(ev)) => {
744 if matches!(
745 &ev,
746 motosan_agent_loop::AgentEvent::Core(
747 motosan_agent_loop::CoreEvent::ThinkingDone(_)
748 )
749 ) {
750 streamed_thinking_seen = true;
751 }
752 if let Some(ui) = map_event(ev, &tracker) {
753 yield ui;
754 }
755 }
756 Some(AgentStreamItem::Terminal(term)) => {
757 terminal_result = Some(term.result);
758 terminal_messages = Some(term.messages);
759 break;
760 }
761 None => break,
762 }
763 }
764 Some(chunk) = progress_guard.recv() => {
765 yield UiEvent::ToolCallProgress {
766 id: progress_event_id(&tracker),
767 chunk: ProgressChunk::from(chunk),
768 };
769 }
770 }
771 }
772
773 interrupt_bridge.abort();
775
776 if let Some(msgs) = terminal_messages.as_ref() {
778 if let Err(err) = session
779 .record_turn_outcome(epoch, previous_len, msgs, branch_parent)
780 .await
781 {
782 yield UiEvent::Error(format!("session.record_turn_outcome: {err}"));
783 }
784 }
785
786 match terminal_result {
788 Some(Ok(result)) => {
789 if !streamed_thinking_seen {
795 if let Some(msgs) = terminal_messages.as_ref() {
796 for ev in extract_new_thinking_events(msgs, previous_len) {
797 yield ev;
798 }
799 }
800 }
801
802 let final_text = terminal_messages
803 .as_ref()
804 .and_then(|msgs| {
805 msgs.iter()
806 .rev()
807 .find(|m| m.role() == motosan_agent_loop::Role::Assistant)
808 .map(|m| m.text())
809 })
810 .unwrap_or_default();
811 if !final_text.is_empty() {
812 yield UiEvent::AgentMessageComplete(final_text);
813 }
814 let usage = result.usage;
817 let (cumulative_input, cumulative_output) = {
818 let mut tally = token_tally.lock().await;
819 tally.add(usage);
820 (tally.cumulative_input, tally.cumulative_output)
821 };
822 yield UiEvent::TurnStats {
823 input_tokens: usage.input_tokens,
824 output_tokens: usage.output_tokens,
825 cumulative_input,
826 cumulative_output,
827 model: settings_model_name.clone(),
828 };
829 while let Ok(chunk) = progress_guard.try_recv() {
831 yield UiEvent::ToolCallProgress {
832 id: progress_event_id(&tracker),
833 chunk: ProgressChunk::from(chunk),
834 };
835 }
836 yield UiEvent::AgentTurnComplete;
837 }
838 Some(Err(err)) => {
839 if let motosan_agent_loop::AgentError::MaxIterations(n) = err {
852 yield UiEvent::Notice {
853 title: "Agent stopped".to_string(),
854 body: format!(
855 "Reached the per-turn iteration cap ({n}). \
856 Partial work is saved — send another message to continue."
857 ),
858 };
859 yield UiEvent::AgentTurnComplete;
860 } else {
861 yield UiEvent::Error(format!("{err}"));
862 }
863 }
864 None => { }
865 }
866 }
867 }
868
869 pub fn send_user_message(
870 &self,
871 msg: crate::user_message::UserMessage,
872 ) -> impl Stream<Item = UiEvent> + Send + 'static {
873 self.run_turn(msg, None)
874 }
875
876 pub fn fork_from(
881 &self,
882 from: motosan_agent_loop::EntryId,
883 message: crate::user_message::UserMessage,
884 ) -> impl Stream<Item = UiEvent> + Send + 'static {
885 let registry = Arc::clone(&self.extension_registry);
886 let inner = self.run_turn(message, Some(from));
887 async_stream::stream! {
888 use crate::extensions::dispatcher::{dispatch_session_before_switch, HookOutcome};
889 match dispatch_session_before_switch(®istry, "fork", None).await {
890 HookOutcome::Continue => {}
891 HookOutcome::Cancelled { extension_name, reason } => {
892 let msg = match reason {
893 Some(r) => format!("extension `{extension_name}` cancelled fork: {r}"),
894 None => format!("extension `{extension_name}` cancelled fork"),
895 };
896 yield UiEvent::Error(msg);
897 return;
898 }
899 }
900 let mut inner = Box::pin(inner);
901 while let Some(ev) = futures::StreamExt::next(&mut inner).await {
902 yield ev;
903 }
904 }
905 }
906
907 pub async fn fork_candidates(&self) -> Result<Vec<(motosan_agent_loop::EntryId, String)>> {
911 let entries = self
912 .session
913 .load_full()
914 .entries()
915 .await
916 .map_err(|e| AppError::Config(format!("entries failed: {e}")))?;
917 let branch = motosan_agent_loop::active_branch(&entries);
918 let mut out: Vec<(motosan_agent_loop::EntryId, String)> = branch
919 .iter()
920 .filter_map(|stored| {
921 let msg = stored.entry.as_message()?;
922 if !matches!(msg.role(), motosan_agent_loop::Role::User) {
923 return None;
924 }
925 let preview: String = msg
926 .text()
927 .lines()
928 .next()
929 .unwrap_or("")
930 .chars()
931 .take(80)
932 .collect();
933 Some((stored.id.clone(), preview))
934 })
935 .collect();
936 out.reverse();
937 Ok(out)
938 }
939
940 pub async fn branches(&self) -> Result<motosan_agent_loop::BranchTree> {
942 self.session
943 .load_full()
944 .branches()
945 .await
946 .map_err(|e| AppError::Config(format!("branches failed: {e}")))
947 }
948
949 async fn fire_session_before_switch(
952 &self,
953 reason: &str,
954 session_id: Option<&str>,
955 ) -> Result<()> {
956 use crate::extensions::dispatcher::{dispatch_session_before_switch, HookOutcome};
957 match dispatch_session_before_switch(&self.extension_registry, reason, session_id).await {
958 HookOutcome::Continue => Ok(()),
959 HookOutcome::Cancelled {
960 extension_name,
961 reason,
962 } => Err(AppError::HookCancelled {
963 extension_name,
964 reason,
965 }),
966 }
967 }
968}
969
970#[derive(Debug, Default)]
971struct ToolCallTracker {
972 next_id: usize,
973 pending: VecDeque<(String, String)>,
974}
975
976impl ToolCallTracker {
977 fn start(&mut self, name: &str) -> String {
978 self.next_id += 1;
979 let id = format!("tool_{}", self.next_id);
980 self.pending.push_back((name.to_string(), id.clone()));
981 id
982 }
983
984 fn complete(&mut self, name: &str) -> String {
985 if let Some(pos) = self
986 .pending
987 .iter()
988 .position(|(pending_name, _)| pending_name == name)
989 {
990 if let Some((_, id)) = self.pending.remove(pos) {
991 return id;
992 }
993 }
994
995 self.next_id += 1;
996 format!("tool_{}", self.next_id)
997 }
998
999 fn progress_id(&self) -> Option<String> {
1004 match self.pending.len() {
1005 1 => self.pending.front().map(|(_, id)| id.clone()),
1006 _ => None,
1007 }
1008 }
1009}
1010
1011fn lock_tool_tracker(tracker: &Arc<Mutex<ToolCallTracker>>) -> MutexGuard<'_, ToolCallTracker> {
1012 match tracker.lock() {
1013 Ok(guard) => guard,
1014 Err(poisoned) => poisoned.into_inner(),
1015 }
1016}
1017
1018fn progress_event_id(tracker: &Arc<Mutex<ToolCallTracker>>) -> String {
1019 lock_tool_tracker(tracker)
1020 .progress_id()
1021 .unwrap_or_else(|| "tool_unknown".to_string())
1022}
1023
1024fn anthropic_api_key_from<F>(auth: &crate::auth::Auth, env_lookup: F) -> Option<String>
1025where
1026 F: Fn(&str) -> Option<String>,
1027{
1028 env_lookup("ANTHROPIC_API_KEY")
1029 .map(|key| key.trim().to_string())
1030 .filter(|key| !key.is_empty())
1031 .or_else(|| auth.api_key("anthropic").map(str::to_string))
1032}
1033
1034fn map_event(ev: AgentEvent, tool_tracker: &Arc<Mutex<ToolCallTracker>>) -> Option<UiEvent> {
1035 match ev {
1036 AgentEvent::Core(CoreEvent::TextChunk(delta)) => Some(UiEvent::AgentTextDelta(delta)),
1037 AgentEvent::Core(CoreEvent::ThinkingChunk(delta)) => {
1038 Some(UiEvent::AgentThinkingDelta(delta))
1039 }
1040 AgentEvent::Core(CoreEvent::ThinkingDone(text)) => Some(UiEvent::ThinkingComplete { text }),
1041 AgentEvent::Core(CoreEvent::ToolStarted { name, args }) => {
1042 let id = lock_tool_tracker(tool_tracker).start(&name);
1043 Some(UiEvent::ToolCallStarted { id, name, args })
1044 }
1045 AgentEvent::Core(CoreEvent::ToolCompleted { name, result }) => {
1046 let id = lock_tool_tracker(tool_tracker).complete(&name);
1047 Some(UiEvent::ToolCallCompleted {
1048 id,
1049 result: UiToolResult {
1050 is_error: result.is_error,
1051 text: format!("{name}: {result:?}"),
1052 },
1053 })
1054 }
1055 AgentEvent::Core(CoreEvent::ExtensionFailed { name, error }) => {
1056 Some(UiEvent::ExtensionFailed {
1059 name: name.to_string(),
1060 error,
1061 })
1062 }
1063 AgentEvent::Extension(ExtensionEvent::Autocompact(
1064 AutocompactEvent::Compacted {
1065 turns_removed,
1066 summary_tokens,
1067 },
1068 )) => Some(UiEvent::Compacted {
1069 turns_removed,
1070 summary_tokens,
1071 source: crate::events::CompactSource::Auto,
1072 }),
1073 _ => None,
1074 }
1075}
1076
1077type CustomToolsFactory = Box<dyn FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>>>;
1078
1079pub struct AppBuilder {
1080 config: Option<Config>,
1081 cwd: Option<PathBuf>,
1082 permission_gate: Option<Arc<dyn PermissionGate>>,
1083 install_builtin_tools: bool,
1084 max_iterations: usize,
1085 llm_override: Option<Arc<dyn LlmClient>>,
1086 custom_tools_factory: Option<CustomToolsFactory>,
1087 permissions_policy_path: Option<PathBuf>,
1088 ui_tx: Option<mpsc::Sender<crate::events::UiEvent>>,
1089 headless_permissions: bool,
1090 settings: Option<crate::settings::Settings>,
1091 auth: Option<crate::auth::Auth>,
1092 context_discovery_disabled: bool,
1093 session_store: Option<Arc<dyn SessionStore>>,
1095 resume_session_id: Option<crate::session::SessionId>,
1096 autocompact_enabled: bool,
1097 skills: Vec<crate::skills::Skill>,
1099 extra_tools: Vec<Arc<dyn motosan_agent_tool::Tool>>,
1101 mcp_servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
1102 extension_registry: Option<Arc<crate::extensions::ExtensionRegistry>>,
1103 extension_diagnostics: Option<Arc<Vec<crate::extensions::ExtensionDiagnostic>>>,
1104 token_tally: Option<Arc<tokio::sync::Mutex<TurnStatsAccum>>>,
1105}
1106
1107impl Default for AppBuilder {
1108 fn default() -> Self {
1109 Self {
1110 config: None,
1111 cwd: None,
1112 permission_gate: None,
1113 install_builtin_tools: false,
1114 max_iterations: 200,
1121 llm_override: None,
1122 custom_tools_factory: None,
1123 permissions_policy_path: None,
1124 ui_tx: None,
1125 headless_permissions: false,
1126 settings: None,
1127 auth: None,
1128 context_discovery_disabled: false,
1129 session_store: None,
1130 resume_session_id: None,
1131 autocompact_enabled: false,
1132 skills: Vec::new(),
1133 extra_tools: Vec::new(),
1134 mcp_servers: Vec::new(),
1135 extension_registry: None,
1136 extension_diagnostics: None,
1137 token_tally: None,
1138 }
1139 }
1140}
1141
1142impl AppBuilder {
1143 pub fn new() -> Self {
1144 Self::default()
1145 }
1146
1147 pub fn with_config(mut self, cfg: Config) -> Self {
1148 self.config = Some(cfg);
1149 self
1150 }
1151
1152 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1153 self.cwd = Some(cwd.into());
1154 self
1155 }
1156
1157 pub fn with_permission_gate(mut self, gate: Arc<dyn PermissionGate>) -> Self {
1158 self.permission_gate = Some(gate);
1159 self
1160 }
1161
1162 pub fn with_builtin_tools(mut self) -> Self {
1168 self.install_builtin_tools = true;
1169 self
1170 }
1171
1172 pub fn with_max_iterations(mut self, n: usize) -> Self {
1173 self.max_iterations = n;
1174 self
1175 }
1176
1177 pub fn with_llm(mut self, llm: Arc<dyn LlmClient>) -> Self {
1178 self.llm_override = Some(llm);
1179 self
1180 }
1181
1182 pub fn with_permissions_config(mut self, path: PathBuf) -> Self {
1183 self.permissions_policy_path = Some(path);
1184 self
1185 }
1186
1187 pub fn with_ui_channel(mut self, tx: mpsc::Sender<UiEvent>) -> Self {
1188 self.ui_tx = Some(tx);
1189 self
1190 }
1191
1192 pub fn with_token_tally(mut self, tally: Arc<tokio::sync::Mutex<TurnStatsAccum>>) -> Self {
1194 self.token_tally = Some(tally);
1195 self
1196 }
1197
1198 pub fn with_headless_permissions(mut self) -> Self {
1203 self.headless_permissions = true;
1204 self
1205 }
1206
1207 pub fn with_settings(mut self, settings: crate::settings::Settings) -> Self {
1209 self.settings = Some(settings);
1210 self
1211 }
1212
1213 pub fn with_auth(mut self, auth: crate::auth::Auth) -> Self {
1215 self.auth = Some(auth);
1216 self
1217 }
1218
1219 pub fn disable_context_discovery(mut self) -> Self {
1222 self.context_discovery_disabled = true;
1223 self
1224 }
1225
1226 pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
1229 self.session_store = Some(store);
1230 self
1231 }
1232
1233 pub fn with_autocompact(mut self) -> Self {
1238 self.autocompact_enabled = true;
1239 self
1240 }
1241
1242 pub fn with_skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
1248 self.skills = skills;
1249 self
1250 }
1251
1252 pub fn without_skills(mut self) -> Self {
1253 self.skills.clear();
1254 self
1255 }
1256
1257 pub fn with_extra_tools(mut self, tools: Vec<Arc<dyn motosan_agent_tool::Tool>>) -> Self {
1261 self.extra_tools = tools;
1262 self
1263 }
1264
1265 pub fn with_extension_registry(
1266 mut self,
1267 registry: Arc<crate::extensions::ExtensionRegistry>,
1268 ) -> Self {
1269 self.extension_registry = Some(registry);
1270 self
1271 }
1272
1273 pub fn with_extension_diagnostics(
1274 mut self,
1275 diagnostics: Arc<Vec<crate::extensions::ExtensionDiagnostic>>,
1276 ) -> Self {
1277 self.extension_diagnostics = Some(diagnostics);
1278 self
1279 }
1280
1281 pub fn with_mcp_servers(
1284 mut self,
1285 servers: Vec<(String, Arc<dyn motosan_agent_loop::mcp::McpServer>)>,
1286 ) -> Self {
1287 self.mcp_servers = servers;
1288 self
1289 }
1290
1291 pub fn with_custom_tools_factory(
1296 mut self,
1297 factory: impl FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>> + 'static,
1298 ) -> Self {
1299 self.custom_tools_factory = Some(Box::new(factory));
1300 self
1301 }
1302
1303 pub async fn build_with_custom_tools(
1307 self,
1308 factory: impl FnOnce(ToolCtx) -> Vec<Arc<dyn motosan_agent_tool::Tool>> + 'static,
1309 ) -> Result<App> {
1310 self.with_custom_tools_factory(factory).build().await
1311 }
1312
1313 pub async fn build_with_session(
1321 mut self,
1322 resume: Option<crate::session::SessionId>,
1323 ) -> Result<App> {
1324 if let Some(id) = resume {
1325 if self.session_store.is_none() {
1326 return Err(AppError::Config(
1327 "build_with_session(Some(id)) requires with_session_store(...)".into(),
1328 ));
1329 }
1330 self.resume_session_id = Some(id);
1331 }
1332 self.build_internal().await
1333 }
1334
1335 pub async fn build(self) -> Result<App> {
1337 self.build_with_session(None).await
1338 }
1339
1340 async fn build_internal(mut self) -> Result<App> {
1341 let mcp_servers = std::mem::take(&mut self.mcp_servers);
1342 let extra_tools = std::mem::take(&mut self.extra_tools);
1343 let skills = self.skills.clone();
1344 if self.install_builtin_tools && self.custom_tools_factory.is_some() {
1345 return Err(AppError::Config(
1346 "with_builtin_tools and with_custom_tools_factory are mutually exclusive".into(),
1347 ));
1348 }
1349
1350 let has_config = self.config.is_some();
1354 let has_auth = self.auth.is_some();
1355 let mut config = self.config.unwrap_or_default();
1356 let settings = match self.settings {
1357 Some(settings) => settings,
1358 None => {
1359 let mut settings = crate::settings::Settings::default();
1360 settings.model.provider = config.model.provider.clone();
1361 settings.model.name = config.model.name.clone();
1362 settings.model.max_tokens = config.model.max_tokens;
1363 settings
1364 }
1365 };
1366 config.model.provider = settings.model.provider.clone();
1367 config.model.name = settings.model.name.clone();
1368 config.model.max_tokens = settings.model.max_tokens;
1369 let mut auth = self.auth.unwrap_or_default();
1370 if !has_auth {
1371 if let Some(key) = config.anthropic.api_key.as_deref() {
1372 auth.0.insert(
1373 "anthropic".into(),
1374 crate::auth::ProviderAuth::ApiKey {
1375 key: key.to_string(),
1376 },
1377 );
1378 }
1379 }
1380 let env_or_auth_key = anthropic_api_key_from(&auth, |name| std::env::var(name).ok());
1381 if env_or_auth_key.is_some() || has_auth || !has_config {
1382 config.anthropic.api_key = env_or_auth_key;
1383 }
1384 let cwd = self
1385 .cwd
1386 .or_else(|| std::env::current_dir().ok())
1387 .unwrap_or_else(|| PathBuf::from("."));
1388 let agent_dir = crate::paths::agent_dir();
1389 let permission_gate = self.permission_gate.unwrap_or_else(|| {
1390 if self.ui_tx.is_some() || self.headless_permissions {
1394 Arc::new(NoOpPermissionGate) as Arc<dyn PermissionGate>
1395 } else {
1396 tracing::warn!("no PermissionGate and no UI channel — tools run unchecked");
1397 Arc::new(NoOpPermissionGate) as Arc<dyn PermissionGate>
1398 }
1399 });
1400
1401 let policy: Arc<crate::permissions::Policy> =
1403 Arc::new(match self.permissions_policy_path.as_ref() {
1404 Some(path) => crate::permissions::Policy::load_or_default(path)?,
1405 None => crate::permissions::Policy::default(),
1406 });
1407 let session_cache = Arc::new(crate::permissions::SessionCache::new());
1408 let permission_strategy_handle = if self.ui_tx.is_some() || self.headless_permissions {
1409 let initial_strategy = if self.ui_tx.is_some() {
1410 PromptStrategy::Prompt
1411 } else {
1412 PromptStrategy::HeadlessDeny
1413 };
1414 Some(Arc::new(tokio::sync::RwLock::new(initial_strategy)))
1415 } else {
1416 None
1417 };
1418 let permission_mode = Arc::new(tokio::sync::RwLock::new(
1419 crate::permissions::PermissionMode::default(),
1420 ));
1421 let token_tally = self
1422 .token_tally
1423 .unwrap_or_else(|| Arc::new(tokio::sync::Mutex::new(TurnStatsAccum::default())));
1424
1425 let (progress_tx, progress_rx) = mpsc::channel::<ToolProgressChunk>(64);
1427
1428 let probe_ctx = ToolCtx::new(&cwd, Arc::clone(&permission_gate), progress_tx.clone());
1433 let cancel_token = probe_ctx.cancel_token.clone();
1434 let (install_builtin, factory_extra_tools): (bool, Vec<Arc<dyn motosan_agent_tool::Tool>>) =
1435 if let Some(factory_fn) = self.custom_tools_factory.take() {
1436 let mut t = factory_fn(probe_ctx);
1437 t.extend(extra_tools.clone());
1438 (false, t)
1439 } else {
1440 (self.install_builtin_tools, extra_tools.clone())
1441 };
1442
1443 let factory = SessionFactory {
1444 cwd: cwd.clone(),
1445 settings: Arc::new(Mutex::new(settings.clone())),
1446 auth: auth.clone(),
1447 policy: Arc::clone(&policy),
1448 session_cache: Arc::clone(&session_cache),
1449 ui_tx: self.ui_tx.clone(),
1450 headless_permissions: self.headless_permissions,
1451 permission_gate: Arc::clone(&permission_gate),
1452 permission_strategy_handle: permission_strategy_handle.clone(),
1453 progress_tx: progress_tx.clone(),
1454 skills: Arc::new(skills.clone()),
1455 install_builtin_tools: install_builtin,
1456 extra_tools: factory_extra_tools,
1457 max_iterations: self.max_iterations,
1458 context_discovery_disabled: self.context_discovery_disabled,
1459 autocompact_enabled: self.autocompact_enabled,
1460 session_store: self.session_store.clone(),
1461 llm_override: self.llm_override.clone(),
1462 current_model: Arc::new(Mutex::new(None)),
1463 cancel_token: cancel_token.clone(),
1464 };
1465
1466 let mode = match self.resume_session_id.take() {
1467 Some(id) => SessionMode::Resume(id.into_string()),
1468 None => SessionMode::New,
1469 };
1470 let (session, llm) = factory.build(mode, None, None).await?;
1471 let (extension_registry, extension_diagnostics) =
1472 if let Some(reg) = self.extension_registry.take() {
1473 let diagnostics = self
1474 .extension_diagnostics
1475 .unwrap_or_else(|| Arc::new(Vec::new()));
1476 (reg, diagnostics)
1477 } else {
1478 let manifest_path = crate::paths::extensions_manifest_path(&agent_dir);
1479 let (registry, diagnostics) =
1480 crate::extensions::load_extensions_manifest(&manifest_path).await;
1481 for d in &diagnostics {
1482 let message = d.message.as_str();
1483 match d.severity {
1484 crate::extensions::DiagnosticSeverity::Warn => {
1485 tracing::warn!("extensions: {message}");
1486 }
1487 crate::extensions::DiagnosticSeverity::Error => {
1488 tracing::error!("extensions: {message}");
1489 }
1490 }
1491 }
1492 (Arc::new(registry), Arc::new(diagnostics))
1493 };
1494
1495 Ok(App {
1496 session: arc_swap::ArcSwap::from_pointee(session),
1497 llm: arc_swap::ArcSwap::from_pointee(SharedLlm::new(llm)),
1498 factory,
1499 config,
1500 cancel_token,
1501 progress_rx: Arc::new(tokio::sync::Mutex::new(progress_rx)),
1502 next_tool_id: Arc::new(Mutex::new(ToolCallTracker::default())),
1503 skills: Arc::new(skills),
1504 mcp_servers,
1505 extension_registry,
1506 token_tally,
1507 extension_diagnostics,
1508 session_cache,
1509 permission_mode,
1510 permission_strategy_handle,
1511 ui_tx_owned: self.ui_tx.clone(),
1512 })
1513 }
1514}
1515
1516#[cfg(test)]
1517mod tests {
1518 use super::*;
1519 use crate::config::{AnthropicConfig, ModelConfig};
1520 use crate::events::UiEvent;
1521 use crate::user_message::UserMessage;
1522 use async_trait::async_trait;
1523 use motosan_agent_loop::{ChatOutput, LlmClient, LlmResponse, Message, ToolCallItem};
1524 use motosan_agent_tool::ToolDef;
1525 use std::sync::atomic::{AtomicUsize, Ordering};
1526
1527 #[test]
1528 fn turn_stats_accum_accumulates_across_calls() {
1529 let mut accum = TurnStatsAccum::default();
1530 accum.add(motosan_agent_loop::TokenUsage {
1531 input_tokens: 100,
1532 output_tokens: 25,
1533 });
1534 accum.add(motosan_agent_loop::TokenUsage {
1535 input_tokens: 1000,
1536 output_tokens: 250,
1537 });
1538 assert_eq!(accum.cumulative_input, 1100);
1539 assert_eq!(accum.cumulative_output, 275);
1540 assert_eq!(accum.turn_count, 2);
1541 }
1542
1543 #[test]
1544 fn turn_stats_accum_saturates_on_overflow() {
1545 let mut accum = TurnStatsAccum {
1546 cumulative_input: u64::MAX - 5,
1547 cumulative_output: 0,
1548 turn_count: 1,
1549 };
1550 accum.add(motosan_agent_loop::TokenUsage {
1551 input_tokens: 100,
1552 output_tokens: 0,
1553 });
1554 assert_eq!(accum.cumulative_input, u64::MAX);
1555 }
1556
1557 #[test]
1558 fn extract_thinking_events_extracts_reasoning_in_source_order() {
1559 use motosan_agent_loop::{new_message_id, AssistantContent, MessageMeta};
1560 let messages = vec![Message::Assistant {
1561 id: new_message_id(),
1562 meta: MessageMeta::default(),
1563 content: vec![
1564 AssistantContent::Reasoning {
1565 text: "first thought".into(),
1566 signature: None,
1567 },
1568 AssistantContent::Text {
1569 text: "answer 1".into(),
1570 },
1571 AssistantContent::Reasoning {
1572 text: "second thought".into(),
1573 signature: None,
1574 },
1575 ],
1576 }];
1577 let events = extract_thinking_events(&messages);
1578 assert_eq!(events.len(), 2);
1579 match (&events[0], &events[1]) {
1580 (UiEvent::ThinkingComplete { text: t1 }, UiEvent::ThinkingComplete { text: t2 }) => {
1581 assert_eq!(t1, "first thought");
1582 assert_eq!(t2, "second thought");
1583 }
1584 _ => panic!("expected two ThinkingComplete events"),
1585 }
1586 }
1587
1588 #[test]
1589 fn extract_new_thinking_events_skips_historical_reasoning_before_previous_len() {
1590 use motosan_agent_loop::{new_message_id, AssistantContent, ContentPart, MessageMeta};
1591 let messages = vec![
1592 Message::User {
1593 id: new_message_id(),
1594 meta: MessageMeta::default(),
1595 content: vec![ContentPart::text("old question")],
1596 },
1597 Message::Assistant {
1598 id: new_message_id(),
1599 meta: MessageMeta::default(),
1600 content: vec![AssistantContent::Reasoning {
1601 text: "old thought".into(),
1602 signature: None,
1603 }],
1604 },
1605 Message::User {
1606 id: new_message_id(),
1607 meta: MessageMeta::default(),
1608 content: vec![ContentPart::text("new question")],
1609 },
1610 Message::Assistant {
1611 id: new_message_id(),
1612 meta: MessageMeta::default(),
1613 content: vec![AssistantContent::Reasoning {
1614 text: "new thought".into(),
1615 signature: None,
1616 }],
1617 },
1618 ];
1619
1620 let events = extract_new_thinking_events(&messages, 2);
1621 assert_eq!(events.len(), 1, "should only emit current-turn reasoning");
1622 match &events[0] {
1623 UiEvent::ThinkingComplete { text } => assert_eq!(text, "new thought"),
1624 other => panic!("expected ThinkingComplete; got {other:?}"),
1625 }
1626 }
1627
1628 #[test]
1629 fn extract_new_thinking_events_still_emits_when_not_suppressed() {
1630 use motosan_agent_loop::{new_message_id, AssistantContent, MessageMeta};
1635 let messages = vec![Message::Assistant {
1636 id: new_message_id(),
1637 meta: MessageMeta::default(),
1638 content: vec![AssistantContent::Reasoning {
1639 text: "the model was thinking".into(),
1640 signature: None,
1641 }],
1642 }];
1643 let events = extract_new_thinking_events(&messages, 0);
1644 assert_eq!(events.len(), 1);
1645 }
1646
1647 #[test]
1648 fn extract_thinking_events_skips_non_assistant_and_non_reasoning() {
1649 use motosan_agent_loop::{new_message_id, AssistantContent, ContentPart, MessageMeta};
1650 let messages = vec![
1651 Message::User {
1652 id: new_message_id(),
1653 meta: MessageMeta::default(),
1654 content: vec![ContentPart::text("question?")],
1655 },
1656 Message::Assistant {
1657 id: new_message_id(),
1658 meta: MessageMeta::default(),
1659 content: vec![AssistantContent::Text {
1660 text: "answer".into(),
1661 }],
1662 },
1663 ];
1664 let events = extract_thinking_events(&messages);
1665 assert!(events.is_empty(), "expected no events; got {events:?}");
1666 }
1667
1668 #[tokio::test]
1669 async fn builder_fails_without_api_key() {
1670 let cfg = Config {
1671 anthropic: AnthropicConfig {
1672 api_key: None,
1673 base_url: "https://api.anthropic.com".into(),
1674 },
1675 model: ModelConfig {
1676 provider: "anthropic".into(),
1677 name: "claude-sonnet-4-6".into(),
1678 max_tokens: 4096,
1679 },
1680 };
1681 let err = match AppBuilder::new()
1682 .with_config(cfg)
1683 .with_builtin_tools()
1684 .build()
1685 .await
1686 {
1687 Ok(_) => panic!("must fail without key"),
1688 Err(err) => err,
1689 };
1690 assert!(format!("{err}").contains("ANTHROPIC_API_KEY"));
1691 }
1692
1693 struct ToolOnlyLlm {
1694 turn: AtomicUsize,
1695 }
1696
1697 #[async_trait]
1698 impl LlmClient for ToolOnlyLlm {
1699 async fn chat(
1700 &self,
1701 _messages: &[Message],
1702 _tools: &[ToolDef],
1703 ) -> motosan_agent_loop::Result<ChatOutput> {
1704 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
1705 if turn == 0 {
1706 Ok(ChatOutput::new(LlmResponse::ToolCalls(vec![
1707 ToolCallItem {
1708 id: "t1".into(),
1709 name: "read".into(),
1710 args: serde_json::json!({"path":"nope.txt"}),
1711 },
1712 ])))
1713 } else {
1714 Ok(ChatOutput::new(LlmResponse::Message(String::new())))
1715 }
1716 }
1717 }
1718
1719 #[tokio::test]
1720 async fn empty_final_message_is_not_emitted() {
1721 let dir = tempfile::tempdir().unwrap();
1722 let mut cfg = Config::default();
1723 cfg.anthropic.api_key = Some("sk-unused".into());
1724 let app = AppBuilder::new()
1725 .with_config(cfg)
1726 .with_cwd(dir.path())
1727 .with_builtin_tools()
1728 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
1729 turn: AtomicUsize::new(0),
1730 }))
1731 .build()
1732 .await
1733 .expect("build");
1734 let events: Vec<UiEvent> =
1735 futures::StreamExt::collect(app.send_user_message(UserMessage::text("x"))).await;
1736 let empties = events
1737 .iter()
1738 .filter(|e| matches!(e, UiEvent::AgentMessageComplete(t) if t.is_empty()))
1739 .count();
1740 assert_eq!(
1741 empties, 0,
1742 "should not emit empty final message, got: {events:?}"
1743 );
1744 }
1745
1746 struct EchoLlm;
1747
1748 struct UsageLlm {
1749 responses: std::sync::Mutex<VecDeque<ChatOutput>>,
1750 }
1751
1752 #[async_trait]
1753 impl LlmClient for UsageLlm {
1754 async fn chat(
1755 &self,
1756 _messages: &[Message],
1757 _tools: &[ToolDef],
1758 ) -> motosan_agent_loop::Result<ChatOutput> {
1759 let next = match self.responses.lock() {
1760 Ok(mut responses) => responses.pop_front(),
1761 Err(poisoned) => poisoned.into_inner().pop_front(),
1762 };
1763 Ok(next.unwrap_or_else(|| panic!("UsageLlm script exhausted")))
1764 }
1765 }
1766
1767 #[async_trait]
1768 impl LlmClient for EchoLlm {
1769 async fn chat(
1770 &self,
1771 _messages: &[Message],
1772 _tools: &[ToolDef],
1773 ) -> motosan_agent_loop::Result<ChatOutput> {
1774 Ok(ChatOutput::new(LlmResponse::Message("ok".into())))
1775 }
1776 }
1777
1778 #[tokio::test]
1779 async fn turn_stats_emitted_with_cumulative_after_each_turn() {
1780 use motosan_agent_loop::{LlmResponse, TokenUsage};
1781
1782 let dir = tempfile::tempdir().expect("tempdir");
1783 let mut cfg = Config::default();
1784 cfg.anthropic.api_key = Some("sk-unused".into());
1785 let llm = Arc::new(UsageLlm {
1786 responses: std::sync::Mutex::new(VecDeque::from([
1787 ChatOutput::with_usage(
1788 LlmResponse::Message("first".into()),
1789 TokenUsage {
1790 input_tokens: 100,
1791 output_tokens: 50,
1792 },
1793 ),
1794 ChatOutput::with_usage(
1795 LlmResponse::Message("second".into()),
1796 TokenUsage {
1797 input_tokens: 200,
1798 output_tokens: 80,
1799 },
1800 ),
1801 ])),
1802 });
1803 let app = AppBuilder::new()
1804 .with_config(cfg)
1805 .with_cwd(dir.path())
1806 .with_builtin_tools()
1807 .with_llm(llm)
1808 .build()
1809 .await
1810 .expect("build");
1811
1812 let events_1: Vec<UiEvent> = app
1813 .send_user_message(UserMessage::text("hi"))
1814 .collect()
1815 .await;
1816 let events_2: Vec<UiEvent> = app
1817 .send_user_message(UserMessage::text("again"))
1818 .collect()
1819 .await;
1820
1821 let ts1 = events_1
1822 .iter()
1823 .find_map(|e| match e {
1824 UiEvent::TurnStats {
1825 input_tokens,
1826 output_tokens,
1827 cumulative_input,
1828 cumulative_output,
1829 ..
1830 } => Some((
1831 *input_tokens,
1832 *output_tokens,
1833 *cumulative_input,
1834 *cumulative_output,
1835 )),
1836 _ => None,
1837 })
1838 .expect("turn 1 had no TurnStats");
1839 assert_eq!(ts1, (100, 50, 100, 50), "turn 1 stats");
1840
1841 let ts2 = events_2
1842 .iter()
1843 .find_map(|e| match e {
1844 UiEvent::TurnStats {
1845 input_tokens,
1846 output_tokens,
1847 cumulative_input,
1848 cumulative_output,
1849 ..
1850 } => Some((
1851 *input_tokens,
1852 *output_tokens,
1853 *cumulative_input,
1854 *cumulative_output,
1855 )),
1856 _ => None,
1857 })
1858 .expect("turn 2 had no TurnStats");
1859 assert_eq!(ts2, (200, 80, 300, 130), "turn 2 stats");
1860
1861 let positions: Vec<&str> = events_1
1862 .iter()
1863 .filter_map(|e| match e {
1864 UiEvent::AgentMessageComplete(_) => Some("msg_complete"),
1865 UiEvent::TurnStats { .. } => Some("stats"),
1866 UiEvent::AgentTurnComplete => Some("turn_complete"),
1867 _ => None,
1868 })
1869 .collect();
1870 assert_eq!(
1871 positions,
1872 vec!["msg_complete", "stats", "turn_complete"],
1873 "wrong ordering"
1874 );
1875 }
1876
1877 #[tokio::test]
1878 async fn turn_stats_reset_after_new_session() {
1879 use motosan_agent_loop::{LlmResponse, TokenUsage};
1880
1881 let dir = tempfile::tempdir().expect("tempdir");
1882 let mut cfg = Config::default();
1883 cfg.anthropic.api_key = Some("sk-unused".into());
1884 let llm = Arc::new(UsageLlm {
1885 responses: std::sync::Mutex::new(VecDeque::from([
1886 ChatOutput::with_usage(
1887 LlmResponse::Message("first".into()),
1888 TokenUsage {
1889 input_tokens: 100,
1890 output_tokens: 50,
1891 },
1892 ),
1893 ChatOutput::with_usage(
1894 LlmResponse::Message("after-new".into()),
1895 TokenUsage {
1896 input_tokens: 7,
1897 output_tokens: 3,
1898 },
1899 ),
1900 ])),
1901 });
1902 let app = AppBuilder::new()
1903 .with_config(cfg)
1904 .with_cwd(dir.path())
1905 .with_builtin_tools()
1906 .with_llm(llm)
1907 .build()
1908 .await
1909 .expect("build");
1910
1911 let _: Vec<UiEvent> = app
1912 .send_user_message(UserMessage::text("hi"))
1913 .collect()
1914 .await;
1915 app.new_session().await.expect("new session");
1916 let events: Vec<UiEvent> = app
1917 .send_user_message(UserMessage::text("after new"))
1918 .collect()
1919 .await;
1920 let stats = events
1921 .iter()
1922 .find_map(|event| match event {
1923 UiEvent::TurnStats {
1924 input_tokens,
1925 output_tokens,
1926 cumulative_input,
1927 cumulative_output,
1928 ..
1929 } => Some((
1930 *input_tokens,
1931 *output_tokens,
1932 *cumulative_input,
1933 *cumulative_output,
1934 )),
1935 _ => None,
1936 })
1937 .expect("turn had no TurnStats");
1938 assert_eq!(stats, (7, 3, 7, 3));
1939 }
1940
1941 #[tokio::test]
1942 async fn with_headless_permissions_builds_an_app() {
1943 let dir = tempfile::tempdir().expect("tempdir");
1944 let mut config = Config::default();
1945 config.anthropic.api_key = Some("sk-unused".into());
1946 let app = AppBuilder::new()
1947 .with_config(config)
1948 .with_cwd(dir.path())
1949 .with_builtin_tools()
1950 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
1951 .with_headless_permissions()
1952 .build()
1953 .await
1954 .expect("build");
1955 assert!(!app.session_id().is_empty());
1957 }
1958
1959 #[tokio::test]
1960 async fn new_session_swaps_in_a_fresh_empty_session() {
1961 use futures::StreamExt;
1962 let dir = tempfile::tempdir().expect("tempdir");
1963 let mut config = Config::default();
1964 config.anthropic.api_key = Some("sk-unused".into());
1965 let app = AppBuilder::new()
1966 .with_config(config)
1967 .with_cwd(dir.path())
1968 .with_builtin_tools()
1969 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
1970 .build()
1971 .await
1972 .expect("build");
1973
1974 let _: Vec<_> = app
1975 .send_user_message(UserMessage::text("hello"))
1976 .collect()
1977 .await;
1978 let id_before = app.session_id();
1979 assert!(!app.session_history().await.expect("history").is_empty());
1980
1981 app.new_session().await.expect("new_session");
1982
1983 assert_ne!(app.session_id(), id_before, "a fresh session has a new id");
1984 assert!(
1985 app.session_history().await.expect("history").is_empty(),
1986 "fresh session has no history"
1987 );
1988 }
1989
1990 #[tokio::test]
1991 async fn load_session_restores_a_stored_session_by_id() {
1992 use futures::StreamExt;
1993 let dir = tempfile::tempdir().expect("tempdir");
1994 let store_dir = dir.path().join("sessions");
1995 let store: Arc<dyn SessionStore> =
1996 Arc::new(motosan_agent_loop::FileSessionStore::new(store_dir));
1997 let mut config = Config::default();
1998 config.anthropic.api_key = Some("sk-unused".into());
1999 let app = AppBuilder::new()
2000 .with_config(config)
2001 .with_cwd(dir.path())
2002 .with_builtin_tools()
2003 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2004 .with_session_store(Arc::clone(&store))
2005 .build()
2006 .await
2007 .expect("build");
2008
2009 let _: Vec<_> = app
2010 .send_user_message(UserMessage::text("remember this"))
2011 .collect()
2012 .await;
2013 let original_id = app.session_id();
2014
2015 app.new_session().await.expect("new_session");
2016 assert_ne!(app.session_id(), original_id);
2017
2018 app.load_session(&original_id).await.expect("load_session");
2019 assert_eq!(app.session_id(), original_id);
2020 let history = app.session_history().await.expect("history");
2021 assert!(
2022 history.iter().any(|m| m.text().contains("remember this")),
2023 "loaded session should carry the original turn"
2024 );
2025 }
2026
2027 #[tokio::test]
2028 async fn clone_session_copies_to_a_new_id_and_switches_to_it() {
2029 use futures::StreamExt;
2030 let dir = tempfile::tempdir().expect("tempdir");
2031 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2032 dir.path().join("s"),
2033 ));
2034 let mut config = Config::default();
2035 config.anthropic.api_key = Some("sk-unused".into());
2036 let app = AppBuilder::new()
2037 .with_config(config)
2038 .with_cwd(dir.path())
2039 .with_builtin_tools()
2040 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2041 .with_session_store(store)
2042 .build()
2043 .await
2044 .expect("build");
2045
2046 let _: Vec<_> = app
2047 .send_user_message(UserMessage::text("hello"))
2048 .collect()
2049 .await;
2050 let original_id = app.session_id();
2051
2052 let new_id = app.clone_session().await.expect("clone_session");
2053
2054 assert_ne!(new_id, original_id);
2056 assert_eq!(app.session_id(), new_id);
2057 let history = app.session_history().await.expect("history");
2059 assert!(history.iter().any(|m| m.text().contains("hello")));
2060 }
2061
2062 #[tokio::test]
2063 async fn fork_from_creates_a_branch_off_an_earlier_entry() {
2064 use futures::StreamExt;
2065 let dir = tempfile::tempdir().expect("tempdir");
2066 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2067 dir.path().join("s"),
2068 ));
2069 let mut config = Config::default();
2070 config.anthropic.api_key = Some("sk-unused".into());
2071 let app = AppBuilder::new()
2072 .with_config(config)
2073 .with_cwd(dir.path())
2074 .with_builtin_tools()
2075 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2076 .with_session_store(store)
2077 .build()
2078 .await
2079 .expect("build");
2080
2081 let _: Vec<_> = app
2083 .send_user_message(UserMessage::text("first"))
2084 .collect()
2085 .await;
2086 let _: Vec<_> = app
2087 .send_user_message(UserMessage::text("second"))
2088 .collect()
2089 .await;
2090
2091 let entries = app.session.load_full().entries().await.expect("entries");
2093 let first_id = entries
2094 .iter()
2095 .find_map(|stored| {
2096 let msg = stored.entry.as_message()?;
2097 (msg.role() == motosan_agent_loop::Role::User && msg.text().contains("first"))
2098 .then(|| stored.id.clone())
2099 })
2100 .expect("first user message present");
2101
2102 let _: Vec<_> = app
2104 .fork_from(first_id, UserMessage::text("branched"))
2105 .collect()
2106 .await;
2107
2108 let history = app.session_history().await.expect("history");
2109 let texts: Vec<String> = history.iter().map(|m| m.text()).collect();
2110 assert!(
2111 texts.iter().any(|t| t.contains("first")),
2112 "fork keeps the fork-point ancestor"
2113 );
2114 assert!(
2115 texts.iter().any(|t| t.contains("branched")),
2116 "fork includes the new message"
2117 );
2118 assert!(
2119 !texts.iter().any(|t| t.contains("second")),
2120 "fork excludes the abandoned branch"
2121 );
2122 }
2123
2124 #[tokio::test]
2125 async fn fork_candidates_lists_active_branch_user_messages_newest_first() {
2126 use futures::StreamExt;
2127 let dir = tempfile::tempdir().expect("tempdir");
2128 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2129 dir.path().join("s"),
2130 ));
2131 let mut config = Config::default();
2132 config.anthropic.api_key = Some("sk-unused".into());
2133 let app = AppBuilder::new()
2134 .with_config(config)
2135 .with_cwd(dir.path())
2136 .with_builtin_tools()
2137 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2138 .with_session_store(store)
2139 .build()
2140 .await
2141 .expect("build");
2142
2143 let _: Vec<_> = app
2144 .send_user_message(UserMessage::text("alpha"))
2145 .collect()
2146 .await;
2147 let _: Vec<_> = app
2148 .send_user_message(UserMessage::text("bravo"))
2149 .collect()
2150 .await;
2151
2152 let candidates = app.fork_candidates().await.expect("candidates");
2153 let previews: Vec<&str> = candidates.iter().map(|(_, p)| p.as_str()).collect();
2154 assert!(previews[0].contains("bravo"), "got {previews:?}");
2156 assert!(previews.iter().any(|p| p.contains("alpha")));
2157 assert!(candidates.iter().all(|(id, _)| !id.is_empty()));
2159 }
2160
2161 #[tokio::test]
2162 async fn branches_returns_a_tree_for_a_linear_session() {
2163 use futures::StreamExt;
2164 let dir = tempfile::tempdir().expect("tempdir");
2165 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2166 dir.path().join("s"),
2167 ));
2168 let mut config = Config::default();
2169 config.anthropic.api_key = Some("sk-unused".into());
2170 let app = AppBuilder::new()
2171 .with_config(config)
2172 .with_cwd(dir.path())
2173 .with_builtin_tools()
2174 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2175 .with_session_store(store)
2176 .build()
2177 .await
2178 .expect("build");
2179
2180 let _: Vec<_> = app
2181 .send_user_message(UserMessage::text("hello"))
2182 .collect()
2183 .await;
2184 let tree = app.branches().await.expect("branches");
2185 assert!(!tree.nodes.is_empty());
2187 assert!(tree.active_leaf.is_some());
2188 }
2189
2190 #[tokio::test]
2191 async fn reload_settings_rebuilds_session_and_resets_token_tally() {
2192 let dir = tempfile::tempdir().expect("tempdir");
2193 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2194 dir.path().join("s"),
2195 ));
2196 let mut cfg = Config::default();
2197 cfg.anthropic.api_key = Some("sk-unused".into());
2198
2199 let llm = Arc::new(UsageLlm {
2200 responses: std::sync::Mutex::new(VecDeque::from([
2201 ChatOutput::with_usage(
2202 LlmResponse::Message("first".into()),
2203 motosan_agent_loop::TokenUsage {
2204 input_tokens: 100,
2205 output_tokens: 50,
2206 },
2207 ),
2208 ChatOutput::with_usage(
2209 LlmResponse::Message("after-reload".into()),
2210 motosan_agent_loop::TokenUsage {
2211 input_tokens: 5,
2212 output_tokens: 2,
2213 },
2214 ),
2215 ])),
2216 });
2217 let app = AppBuilder::new()
2218 .with_config(cfg)
2219 .with_cwd(dir.path())
2220 .with_builtin_tools()
2221 .with_llm(llm)
2222 .with_session_store(store)
2223 .build()
2224 .await
2225 .expect("build");
2226
2227 let _: Vec<UiEvent> = app
2228 .send_user_message(crate::user_message::UserMessage::text("hi"))
2229 .collect()
2230 .await;
2231 {
2232 let token_tally = app.token_tally();
2233 let tally = token_tally.lock().await;
2234 assert_eq!(tally.cumulative_input, 100);
2235 assert_eq!(tally.cumulative_output, 50);
2236 assert_eq!(tally.turn_count, 1);
2237 }
2238
2239 let mut new_settings = crate::settings::Settings::default();
2240 new_settings.model.name = "claude-opus-4-7".into();
2241 new_settings.ui.footer_show_cost = false;
2242 app.reload_settings(new_settings).await.expect("reload");
2243 assert_eq!(app.factory.settings().model.name, "claude-opus-4-7");
2244 assert!(!app.factory.settings().ui.footer_show_cost);
2245 assert_eq!(app.factory.current_model(), None);
2246
2247 {
2248 let token_tally = app.token_tally();
2249 let tally = token_tally.lock().await;
2250 assert_eq!(tally.cumulative_input, 0, "tally not reset on reload");
2251 assert_eq!(tally.cumulative_output, 0);
2252 assert_eq!(tally.turn_count, 0);
2253 }
2254
2255 let _: Vec<UiEvent> = app
2256 .send_user_message(crate::user_message::UserMessage::text("after"))
2257 .collect()
2258 .await;
2259 {
2260 let token_tally = app.token_tally();
2261 let tally = token_tally.lock().await;
2262 assert_eq!(tally.cumulative_input, 5);
2263 assert_eq!(tally.cumulative_output, 2);
2264 assert_eq!(tally.turn_count, 1);
2265 }
2266 }
2267
2268 #[tokio::test]
2269 async fn switch_model_preserves_history() {
2270 use futures::StreamExt;
2271 let dir = tempfile::tempdir().expect("tempdir");
2272 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2273 dir.path().join("s"),
2274 ));
2275 let mut config = Config::default();
2276 config.anthropic.api_key = Some("sk-unused".into());
2277 let app = AppBuilder::new()
2278 .with_config(config)
2279 .with_cwd(dir.path())
2280 .with_builtin_tools()
2281 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2282 .with_session_store(store)
2283 .build()
2284 .await
2285 .expect("build");
2286
2287 let _: Vec<_> = app
2288 .send_user_message(UserMessage::text("keep me"))
2289 .collect()
2290 .await;
2291 let id_before = app.session_id();
2292
2293 app.switch_model(&crate::model::ModelId::from("claude-opus-4-7"))
2294 .await
2295 .expect("switch_model");
2296
2297 assert_eq!(
2298 app.session_id(),
2299 id_before,
2300 "switch_model keeps the same session"
2301 );
2302 let history = app.session_history().await.expect("history");
2303 assert!(history.iter().any(|m| m.text().contains("keep me")));
2304 }
2305
2306 #[tokio::test]
2307 async fn switch_model_is_sticky_for_future_session_rebuilds() {
2308 let dir = tempfile::tempdir().expect("tempdir");
2309 let store: Arc<dyn SessionStore> = Arc::new(motosan_agent_loop::FileSessionStore::new(
2310 dir.path().join("s"),
2311 ));
2312 let mut config = Config::default();
2313 config.anthropic.api_key = Some("sk-unused".into());
2314 let app = AppBuilder::new()
2315 .with_config(config)
2316 .with_cwd(dir.path())
2317 .with_builtin_tools()
2318 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2319 .with_session_store(store)
2320 .build()
2321 .await
2322 .expect("build");
2323
2324 let selected = crate::model::ModelId::from("claude-opus-4-7");
2325 app.switch_model(&selected).await.expect("switch_model");
2326 app.new_session().await.expect("new_session");
2327
2328 assert_eq!(app.factory.current_model(), Some(selected.clone()));
2329 assert_eq!(app.settings().model.name, selected.to_string());
2330 }
2331
2332 struct SleepThenDoneLlm {
2333 turn: AtomicUsize,
2334 }
2335
2336 #[async_trait]
2337 impl LlmClient for SleepThenDoneLlm {
2338 async fn chat(
2339 &self,
2340 _messages: &[Message],
2341 _tools: &[ToolDef],
2342 ) -> motosan_agent_loop::Result<ChatOutput> {
2343 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
2344 if turn == 0 {
2345 Ok(ChatOutput::new(LlmResponse::ToolCalls(vec![
2346 ToolCallItem {
2347 id: "sleep".into(),
2348 name: "bash".into(),
2349 args: serde_json::json!({"command":"sleep 5", "timeout_ms": 10000}),
2350 },
2351 ])))
2352 } else {
2353 Ok(ChatOutput::new(LlmResponse::Message("done".into())))
2354 }
2355 }
2356 }
2357
2358 #[tokio::test]
2359 async fn cancel_reaches_builtin_tools_after_session_rebuild() {
2360 use futures::StreamExt;
2361 let dir = tempfile::tempdir().expect("tempdir");
2362 let mut config = Config::default();
2363 config.anthropic.api_key = Some("sk-unused".into());
2364 let app = Arc::new(
2365 AppBuilder::new()
2366 .with_config(config)
2367 .with_cwd(dir.path())
2368 .with_builtin_tools()
2369 .with_llm(Arc::new(SleepThenDoneLlm {
2370 turn: AtomicUsize::new(0),
2371 }) as Arc<dyn LlmClient>)
2372 .build()
2373 .await
2374 .expect("build"),
2375 );
2376
2377 app.new_session().await.expect("new_session");
2378 let running_app = Arc::clone(&app);
2379 let handle = tokio::spawn(async move {
2380 running_app
2381 .send_user_message(UserMessage::text("run a slow command"))
2382 .collect::<Vec<_>>()
2383 .await
2384 });
2385 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2386 app.cancel();
2387
2388 let events = tokio::time::timeout(std::time::Duration::from_secs(2), handle)
2389 .await
2390 .expect("turn should finish after cancellation")
2391 .expect("join");
2392 assert!(
2393 events.iter().any(|event| {
2394 matches!(
2395 event,
2396 UiEvent::ToolCallCompleted { result, .. }
2397 if result.text.contains("command cancelled by user")
2398 )
2399 }),
2400 "cancel should reach the rebuilt bash tool: {events:?}"
2401 );
2402 }
2403
2404 #[tokio::test]
2405 async fn compact_summarizes_a_session_with_enough_history() {
2406 struct DoneLlm;
2407 #[async_trait]
2408 impl LlmClient for DoneLlm {
2409 async fn chat(
2410 &self,
2411 _messages: &[Message],
2412 _tools: &[ToolDef],
2413 ) -> motosan_agent_loop::Result<ChatOutput> {
2414 Ok(ChatOutput::new(LlmResponse::Message("done".into())))
2415 }
2416 }
2417
2418 let dir = tempfile::tempdir().expect("tempdir");
2419 let store = Arc::new(motosan_agent_loop::FileSessionStore::new(
2420 dir.path().join("sessions"),
2421 ));
2422 let mut config = Config::default();
2423 config.anthropic.api_key = Some("sk-unused".into());
2424 let app = AppBuilder::new()
2425 .with_config(config)
2426 .with_cwd(dir.path())
2427 .with_builtin_tools()
2428 .with_llm(Arc::new(DoneLlm) as Arc<dyn LlmClient>)
2429 .with_session_store(store)
2430 .build()
2431 .await
2432 .expect("build");
2433
2434 for i in 0..4 {
2439 let _: Vec<_> = app
2440 .send_user_message(UserMessage::text(format!("turn {i}")))
2441 .collect()
2442 .await;
2443 }
2444
2445 let result = app.compact().await.expect("compact should succeed");
2446 assert!(
2447 result.is_some(),
2448 "compact should return Some(CompactionResult) when there's enough history"
2449 );
2450 let r = result.unwrap();
2451 assert!(!r.summary.is_empty(), "summary should be non-empty");
2452
2453 let history = app.session_history().await.expect("history");
2456 assert!(
2457 !history.is_empty(),
2458 "session should still have content post-compaction"
2459 );
2460 }
2461
2462 #[tokio::test]
2463 async fn compact_returns_none_on_session_without_user_messages() {
2464 let dir = tempfile::tempdir().expect("tempdir");
2469 let store = Arc::new(motosan_agent_loop::FileSessionStore::new(
2470 dir.path().join("sessions"),
2471 ));
2472 let mut config = Config::default();
2473 config.anthropic.api_key = Some("sk-unused".into());
2474 let app = AppBuilder::new()
2475 .with_config(config)
2476 .with_cwd(dir.path())
2477 .with_builtin_tools()
2478 .with_llm(Arc::new(EchoLlm) as Arc<dyn LlmClient>)
2479 .with_session_store(store)
2480 .build()
2481 .await
2482 .expect("build");
2483
2484 let result = app.compact().await.expect("compact should succeed");
2485
2486 assert!(result.is_none(), "fresh session must yield Ok(None)");
2487 }
2488
2489 #[test]
2490 fn anthropic_env_api_key_overrides_auth_json_key() {
2491 let mut auth = crate::auth::Auth::default();
2492 auth.0.insert(
2493 "anthropic".into(),
2494 crate::auth::ProviderAuth::ApiKey {
2495 key: "sk-auth".into(),
2496 },
2497 );
2498
2499 let key = anthropic_api_key_from(&auth, |name| {
2500 (name == "ANTHROPIC_API_KEY").then(|| " sk-env ".to_string())
2501 });
2502 assert_eq!(key.as_deref(), Some("sk-env"));
2503 }
2504
2505 #[tokio::test]
2506 async fn with_settings_overrides_deprecated_config_model() {
2507 use crate::settings::Settings;
2508
2509 let mut config = Config::default();
2510 config.model.name = "from-config".into();
2511 config.anthropic.api_key = Some("sk-config".into());
2512
2513 let mut settings = Settings::default();
2514 settings.model.name = "from-settings".into();
2515
2516 let tmp = tempfile::tempdir().unwrap();
2517 let app = AppBuilder::new()
2518 .with_config(config)
2519 .with_settings(settings)
2520 .with_cwd(tmp.path())
2521 .disable_context_discovery()
2522 .with_llm(Arc::new(EchoLlm))
2523 .build()
2524 .await
2525 .expect("build");
2526 assert_eq!(app.config().model.name, "from-settings");
2527 assert_eq!(app.config().anthropic.api_key.as_deref(), Some("sk-config"));
2528 }
2529
2530 #[tokio::test]
2531 async fn with_settings_synthesises_legacy_config_for_build() {
2532 use crate::auth::{Auth, ProviderAuth};
2533 use crate::settings::Settings;
2534
2535 let mut settings = Settings::default();
2536 settings.model.name = "claude-sonnet-4-6".into();
2537
2538 let mut auth = Auth::default();
2539 auth.0.insert(
2540 "anthropic".into(),
2541 ProviderAuth::ApiKey {
2542 key: "sk-test".into(),
2543 },
2544 );
2545
2546 let tmp = tempfile::tempdir().unwrap();
2547 let app = AppBuilder::new()
2548 .with_settings(settings)
2549 .with_auth(auth)
2550 .with_cwd(tmp.path())
2551 .with_builtin_tools()
2552 .disable_context_discovery()
2553 .with_llm(Arc::new(EchoLlm))
2554 .build()
2555 .await
2556 .expect("build");
2557 let _ = app;
2558 }
2559
2560 #[tokio::test]
2561 async fn cancel_before_turn_does_not_poison_future_turns() {
2562 let dir = tempfile::tempdir().unwrap();
2563 let mut cfg = Config::default();
2564 cfg.anthropic.api_key = Some("sk-unused".into());
2565 let app = AppBuilder::new()
2566 .with_config(cfg)
2567 .with_cwd(dir.path())
2568 .with_builtin_tools()
2569 .with_llm(std::sync::Arc::new(EchoLlm))
2570 .build()
2571 .await
2572 .expect("build");
2573
2574 app.cancel();
2575 let events: Vec<UiEvent> = app
2576 .send_user_message(UserMessage::text("x"))
2577 .collect()
2578 .await;
2579
2580 assert!(
2581 events
2582 .iter()
2583 .any(|e| matches!(e, UiEvent::AgentMessageComplete(text) if text == "ok")),
2584 "turn should use a fresh cancellation token: {events:?}"
2585 );
2586 }
2587
2588 #[test]
2589 fn map_event_matches_started_and_completed_ids_by_tool_name() {
2590 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2591
2592 let started_bash = map_event(
2593 AgentEvent::Core(CoreEvent::ToolStarted {
2594 name: "bash".into(),
2595 args: serde_json::json!({}),
2596 }),
2597 &tracker,
2598 );
2599 let started_read = map_event(
2600 AgentEvent::Core(CoreEvent::ToolStarted {
2601 name: "read".into(),
2602 args: serde_json::json!({}),
2603 }),
2604 &tracker,
2605 );
2606 let completed_bash = map_event(
2607 AgentEvent::Core(CoreEvent::ToolCompleted {
2608 name: "bash".into(),
2609 result: motosan_agent_tool::ToolResult::text("ok"),
2610 }),
2611 &tracker,
2612 );
2613 let completed_read = map_event(
2614 AgentEvent::Core(CoreEvent::ToolCompleted {
2615 name: "read".into(),
2616 result: motosan_agent_tool::ToolResult::text("ok"),
2617 }),
2618 &tracker,
2619 );
2620
2621 assert!(matches!(
2622 started_bash,
2623 Some(UiEvent::ToolCallStarted { ref id, ref name, .. }) if id == "tool_1" && name == "bash"
2624 ));
2625 assert!(matches!(
2626 started_read,
2627 Some(UiEvent::ToolCallStarted { ref id, ref name, .. }) if id == "tool_2" && name == "read"
2628 ));
2629 assert!(matches!(
2630 completed_bash,
2631 Some(UiEvent::ToolCallCompleted { ref id, .. }) if id == "tool_1"
2632 ));
2633 assert!(matches!(
2634 completed_read,
2635 Some(UiEvent::ToolCallCompleted { ref id, .. }) if id == "tool_2"
2636 ));
2637 }
2638
2639 #[test]
2640 fn map_event_forwards_thinking_chunk_as_delta() {
2641 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2642 let ev = AgentEvent::Core(CoreEvent::ThinkingChunk("partial ".into()));
2643 match map_event(ev, &tracker) {
2644 Some(UiEvent::AgentThinkingDelta(s)) => assert_eq!(s, "partial "),
2645 other => panic!("expected AgentThinkingDelta, got {other:?}"),
2646 }
2647 }
2648
2649 #[test]
2650 fn map_event_forwards_thinking_done_as_complete() {
2651 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2652 let ev = AgentEvent::Core(CoreEvent::ThinkingDone("full thought".into()));
2653 match map_event(ev, &tracker) {
2654 Some(UiEvent::ThinkingComplete { text }) => assert_eq!(text, "full thought"),
2655 other => panic!("expected ThinkingComplete, got {other:?}"),
2656 }
2657 }
2658
2659 #[test]
2660 fn map_event_forwards_tool_started_args() {
2661 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2666 let args = serde_json::json!({"path": "src/main.rs", "limit": 20});
2667 let mapped = map_event(
2668 AgentEvent::Core(CoreEvent::ToolStarted {
2669 name: "read".into(),
2670 args: args.clone(),
2671 }),
2672 &tracker,
2673 );
2674 match mapped {
2675 Some(UiEvent::ToolCallStarted {
2676 args: forwarded, ..
2677 }) => assert_eq!(forwarded, args, "args must round-trip from CoreEvent"),
2678 other => panic!("expected ToolCallStarted; got {other:?}"),
2679 }
2680 }
2681
2682 #[test]
2683 fn map_event_surfaces_autocompact_compacted() {
2684 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2685 let ev = AgentEvent::Extension(ExtensionEvent::Autocompact(
2686 AutocompactEvent::Compacted {
2687 turns_removed: 7,
2688 summary_tokens: 142,
2689 },
2690 ));
2691 let mapped = map_event(ev, &tracker);
2692 match mapped {
2693 Some(UiEvent::Compacted {
2694 turns_removed,
2695 summary_tokens,
2696 source,
2697 }) => {
2698 assert_eq!(turns_removed, 7);
2699 assert_eq!(summary_tokens, 142);
2700 assert_eq!(source, crate::events::CompactSource::Auto);
2701 }
2702 other => panic!("expected Compacted, got {other:?}"),
2703 }
2704 }
2705
2706 #[test]
2707 fn map_event_surfaces_extension_failed() {
2708 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2709 let ev = AgentEvent::Core(CoreEvent::ExtensionFailed {
2710 name: "autocompact",
2711 error: "panicked during transform_request".into(),
2712 });
2713 let mapped = map_event(ev, &tracker);
2714 match mapped {
2715 Some(UiEvent::ExtensionFailed { name, error }) => {
2716 assert_eq!(name, "autocompact");
2717 assert!(error.contains("panicked"));
2718 }
2719 other => panic!("expected ExtensionFailed, got {other:?}"),
2720 }
2721 }
2722
2723 #[test]
2724 fn tool_tracker_handles_two_concurrent_invocations_of_same_tool() {
2725 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2726 let s1 = map_event(
2727 AgentEvent::Core(CoreEvent::ToolStarted {
2728 name: "bash".into(),
2729 args: serde_json::json!({}),
2730 }),
2731 &tracker,
2732 );
2733 let s2 = map_event(
2734 AgentEvent::Core(CoreEvent::ToolStarted {
2735 name: "bash".into(),
2736 args: serde_json::json!({}),
2737 }),
2738 &tracker,
2739 );
2740 let c1 = map_event(
2741 AgentEvent::Core(CoreEvent::ToolCompleted {
2742 name: "bash".into(),
2743 result: motosan_agent_tool::ToolResult::text("a"),
2744 }),
2745 &tracker,
2746 );
2747 let c2 = map_event(
2748 AgentEvent::Core(CoreEvent::ToolCompleted {
2749 name: "bash".into(),
2750 result: motosan_agent_tool::ToolResult::text("b"),
2751 }),
2752 &tracker,
2753 );
2754
2755 let id_s1 = match s1 {
2756 Some(UiEvent::ToolCallStarted { id, .. }) => id,
2757 other => panic!("{other:?}"),
2758 };
2759 let id_s2 = match s2 {
2760 Some(UiEvent::ToolCallStarted { id, .. }) => id,
2761 other => panic!("{other:?}"),
2762 };
2763 let id_c1 = match c1 {
2764 Some(UiEvent::ToolCallCompleted { id, .. }) => id,
2765 other => panic!("{other:?}"),
2766 };
2767 let id_c2 = match c2 {
2768 Some(UiEvent::ToolCallCompleted { id, .. }) => id,
2769 other => panic!("{other:?}"),
2770 };
2771
2772 assert_eq!(id_s1, id_c1);
2773 assert_eq!(id_s2, id_c2);
2774 assert_ne!(id_s1, id_s2);
2775 }
2776
2777 #[tokio::test]
2778 async fn concurrent_send_user_message_returns_an_error_instead_of_hanging() {
2779 let dir = tempfile::tempdir().unwrap();
2780 let mut cfg = Config::default();
2781 cfg.anthropic.api_key = Some("sk-unused".into());
2782 let app = AppBuilder::new()
2783 .with_config(cfg)
2784 .with_cwd(dir.path())
2785 .with_builtin_tools()
2786 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
2787 turn: AtomicUsize::new(0),
2788 }))
2789 .build()
2790 .await
2791 .expect("build");
2792
2793 let mut first = Box::pin(app.send_user_message(UserMessage::text("first")));
2794 let first_event = first.next().await;
2795 assert!(matches!(first_event, Some(UiEvent::AgentTurnStarted)));
2796
2797 let second_events: Vec<UiEvent> = app
2798 .send_user_message(UserMessage::text("second"))
2799 .collect()
2800 .await;
2801 assert_eq!(
2802 second_events.len(),
2803 1,
2804 "expected immediate single error event, got: {second_events:?}"
2805 );
2806 assert!(matches!(
2807 &second_events[0],
2808 UiEvent::Error(msg) if msg.contains("single-turn-per-App")
2809 ));
2810 }
2811
2812 #[tokio::test]
2813 async fn attachment_error_is_emitted_before_the_single_turn_guard_fires() {
2814 let dir = tempfile::tempdir().unwrap();
2820 let mut cfg = Config::default();
2821 cfg.anthropic.api_key = Some("sk-unused".into());
2822 let app = AppBuilder::new()
2823 .with_config(cfg)
2824 .with_cwd(dir.path())
2825 .with_builtin_tools()
2826 .with_llm(std::sync::Arc::new(ToolOnlyLlm {
2827 turn: AtomicUsize::new(0),
2828 }))
2829 .build()
2830 .await
2831 .expect("build");
2832
2833 let mut first =
2835 Box::pin(app.send_user_message(crate::user_message::UserMessage::text("first")));
2836 let first_event = first.next().await;
2837 assert!(matches!(first_event, Some(UiEvent::AgentTurnStarted)));
2838
2839 let bad = crate::user_message::UserMessage {
2841 text: "second".into(),
2842 attachments: vec![crate::user_message::Attachment::Image {
2843 path: std::path::PathBuf::from("/tmp/this-file-must-not-exist-capo-v06-test.png"),
2844 }],
2845 };
2846 let second_events: Vec<UiEvent> = app.send_user_message(bad).collect().await;
2847
2848 assert_eq!(
2849 second_events.len(),
2850 1,
2851 "expected exactly one event (the attachment error); got: {second_events:?}"
2852 );
2853 assert!(
2854 matches!(
2855 &second_events[0],
2856 UiEvent::AttachmentError {
2857 kind: crate::user_message::AttachmentErrorKind::NotFound,
2858 ..
2859 }
2860 ),
2861 "expected AttachmentError::NotFound as first event; got {second_events:?}"
2862 );
2863 }
2864
2865 struct InfiniteToolLlm;
2866
2867 #[async_trait]
2868 impl LlmClient for InfiniteToolLlm {
2869 async fn chat(
2870 &self,
2871 _messages: &[Message],
2872 _tools: &[ToolDef],
2873 ) -> motosan_agent_loop::Result<ChatOutput> {
2874 Ok(ChatOutput::new(LlmResponse::ToolCalls(vec![
2877 ToolCallItem {
2878 id: "loop".into(),
2879 name: "read".into(),
2880 args: serde_json::json!({"path": "nope.txt"}),
2881 },
2882 ])))
2883 }
2884 }
2885
2886 #[tokio::test]
2887 async fn max_iterations_surfaces_as_notice_with_lifecycle_complete() {
2888 let dir = tempfile::tempdir().unwrap();
2889 let mut cfg = Config::default();
2890 cfg.anthropic.api_key = Some("sk-unused".into());
2891 let app = AppBuilder::new()
2892 .with_config(cfg)
2893 .with_cwd(dir.path())
2894 .with_builtin_tools()
2895 .with_llm(std::sync::Arc::new(InfiniteToolLlm))
2896 .with_max_iterations(3)
2897 .build()
2898 .await
2899 .expect("build");
2900
2901 let events: Vec<UiEvent> =
2902 futures::StreamExt::collect(app.send_user_message(UserMessage::text("loop"))).await;
2903
2904 assert!(
2905 !events.iter().any(|e| matches!(e, UiEvent::Error(_))),
2906 "MaxIterations should surface as Notice, not Error; got: {events:?}"
2907 );
2908
2909 let notice = events.iter().find_map(|e| match e {
2910 UiEvent::Notice { title, body } => Some((title.clone(), body.clone())),
2911 _ => None,
2912 });
2913 let (title, body) =
2914 notice.unwrap_or_else(|| panic!("expected Notice event; got: {events:?}"));
2915 let title_lower = title.to_lowercase();
2916 assert!(
2917 title_lower.contains("stop") || title_lower.contains("iteration"),
2918 "notice title should mention stop/iteration; got title={title:?} body={body:?}"
2919 );
2920 assert!(
2921 body.contains("3"),
2922 "body should reference the per-turn cap (3); got body={body:?}"
2923 );
2924 let body_lower = body.to_lowercase();
2925 assert!(
2926 body_lower.contains("continue") || body_lower.contains("send another"),
2927 "body should hint that user can continue; got body={body:?}"
2928 );
2929
2930 assert!(
2931 events
2932 .iter()
2933 .any(|e| matches!(e, UiEvent::AgentTurnComplete)),
2934 "AgentTurnComplete should fire on the soft-cap path; got: {events:?}"
2935 );
2936 }
2937
2938 #[test]
2939 fn progress_events_only_claim_an_id_when_exactly_one_tool_is_pending() {
2940 let tracker = Arc::new(Mutex::new(ToolCallTracker::default()));
2941 assert_eq!(progress_event_id(&tracker), "tool_unknown");
2942
2943 let only = map_event(
2944 AgentEvent::Core(CoreEvent::ToolStarted {
2945 name: "bash".into(),
2946 args: serde_json::json!({}),
2947 }),
2948 &tracker,
2949 );
2950 let only_id = match only {
2951 Some(UiEvent::ToolCallStarted { id, .. }) => id,
2952 other => panic!("{other:?}"),
2953 };
2954 assert_eq!(progress_event_id(&tracker), only_id);
2955
2956 let _second = map_event(
2957 AgentEvent::Core(CoreEvent::ToolStarted {
2958 name: "read".into(),
2959 args: serde_json::json!({}),
2960 }),
2961 &tracker,
2962 );
2963 assert_eq!(progress_event_id(&tracker), "tool_unknown");
2964 }
2965
2966 #[tokio::test]
2967 async fn builder_rejects_builtin_and_custom_tools_together() {
2968 let mut cfg = Config::default();
2969 cfg.anthropic.api_key = Some("sk-unused".into());
2970 let dir = tempfile::tempdir().unwrap();
2971 let err = match AppBuilder::new()
2972 .with_config(cfg)
2973 .with_cwd(dir.path())
2974 .with_builtin_tools()
2975 .with_custom_tools_factory(|_| Vec::new())
2976 .build()
2977 .await
2978 {
2979 Ok(_) => panic!("must reject conflicting tool configuration"),
2980 Err(err) => err,
2981 };
2982
2983 assert!(format!("{err}").contains("mutually exclusive"));
2984 }
2985
2986 #[tokio::test]
2988 async fn two_turns_in_same_session_share_history() {
2989 #[derive(Default)]
2990 struct CounterLlm {
2991 turn: AtomicUsize,
2992 }
2993 #[async_trait]
2994 impl LlmClient for CounterLlm {
2995 async fn chat(
2996 &self,
2997 messages: &[Message],
2998 _tools: &[ToolDef],
2999 ) -> motosan_agent_loop::Result<ChatOutput> {
3000 let turn = self.turn.fetch_add(1, Ordering::SeqCst);
3001 let answer = format!("turn-{turn}-saw-{}-messages", messages.len());
3002 Ok(ChatOutput::new(LlmResponse::Message(answer)))
3003 }
3004 }
3005
3006 let tmp = tempfile::tempdir().unwrap();
3007 let store = std::sync::Arc::new(motosan_agent_loop::FileSessionStore::new(
3008 tmp.path().to_path_buf(),
3009 ));
3010
3011 let app = AppBuilder::new()
3012 .with_settings(crate::settings::Settings::default())
3013 .with_auth(crate::auth::Auth::default())
3014 .with_cwd(tmp.path())
3015 .with_builtin_tools()
3016 .disable_context_discovery()
3017 .with_llm(std::sync::Arc::new(CounterLlm::default()))
3018 .with_session_store(store)
3019 .build_with_session(None)
3020 .await
3021 .expect("build");
3022
3023 let _events1: Vec<UiEvent> = app
3024 .send_user_message(UserMessage::text("hi"))
3025 .collect()
3026 .await;
3027 let events2: Vec<UiEvent> = app
3028 .send_user_message(UserMessage::text("again"))
3029 .collect()
3030 .await;
3031
3032 let saw_more_than_one = events2.iter().any(|e| {
3034 matches!(
3035 e,
3036 UiEvent::AgentMessageComplete(t) if t.contains("messages") && !t.contains("saw-1-")
3037 )
3038 });
3039 assert!(
3040 saw_more_than_one,
3041 "second turn should have seen history; events: {events2:?}"
3042 );
3043 }
3044}
3045
3046#[cfg(test)]
3047mod skills_builder_tests {
3048 use super::*;
3049 use crate::skills::types::{Skill, SkillSource};
3050 use std::path::PathBuf;
3051
3052 fn fixture() -> Skill {
3053 Skill {
3054 name: "x".into(),
3055 description: "d".into(),
3056 file_path: PathBuf::from("/x.md"),
3057 base_dir: PathBuf::from("/"),
3058 disable_model_invocation: false,
3059 source: SkillSource::Global,
3060 }
3061 }
3062
3063 #[test]
3064 fn with_skills_stores_skills() {
3065 let b = AppBuilder::new().with_skills(vec![fixture()]);
3066 assert_eq!(b.skills.len(), 1);
3067 assert_eq!(b.skills[0].name, "x");
3068 }
3069
3070 #[test]
3071 fn without_skills_clears() {
3072 let b = AppBuilder::new()
3073 .with_skills(vec![fixture()])
3074 .without_skills();
3075 assert!(b.skills.is_empty());
3076 }
3077}
3078
3079#[cfg(test)]
3080mod mcp_builder_tests {
3081 use super::*;
3082 use motosan_agent_tool::Tool;
3083
3084 struct FakeTool;
3086 impl Tool for FakeTool {
3087 fn def(&self) -> motosan_agent_tool::ToolDef {
3088 motosan_agent_tool::ToolDef {
3089 name: "fake__echo".into(),
3090 description: "test".into(),
3091 input_schema: serde_json::json!({"type": "object"}),
3092 }
3093 }
3094 fn call(
3095 &self,
3096 _args: serde_json::Value,
3097 _ctx: &motosan_agent_tool::ToolContext,
3098 ) -> std::pin::Pin<
3099 Box<dyn std::future::Future<Output = motosan_agent_tool::ToolResult> + Send + '_>,
3100 > {
3101 Box::pin(async { motosan_agent_tool::ToolResult::text("ok") })
3102 }
3103 }
3104
3105 #[test]
3106 fn with_extra_tools_stores_tools() {
3107 let tools: Vec<Arc<dyn Tool>> = vec![Arc::new(FakeTool)];
3108 let b = AppBuilder::new().with_extra_tools(tools);
3109 assert_eq!(b.extra_tools.len(), 1);
3110 }
3111}