Skip to main content

capo_agent/
app.rs

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