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