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