Skip to main content

mermaid_cli/effect/
mod.rs

1//! The effect runner: dispatches `Cmd` values into tokio tasks.
2//!
3//! There are exactly two places in the codebase that spawn a tokio
4//! task: this module and tests. Everywhere else asks the
5//! reducer to return a `Cmd`, and the runner handles it. That
6//! centralization is what makes structured concurrency per turn
7//! actually work — nothing can accidentally spawn a detached task
8//! that outlives the turn it was started for.
9//!
10//! Architecture:
11//!
12//! ```text
13//!   main loop ── reducer ── Cmd ── dispatch ── EffectRunner
14//!                                                 ├── TurnScope(turn A) ── JoinSet
15//!                                                 ├── TurnScope(turn B) ── JoinSet
16//!                                                 └── detached effects (Save, Exit, …)
17//!                                                       ↓
18//!                                              Msg via mpsc::Sender<Msg>
19//!                                                       ↓
20//!                                                 main loop (next iteration)
21//! ```
22//!
23//! The runner dispatches every `Cmd` variant to a real handler —
24//! model streaming (`CallModel` → `ModelProvider::chat`), tool
25//! execution (`ExecuteTool` → `ToolExecutor::execute`), persistence
26//! (`SaveConversation`, `LoadConversation`, `PersistLastModel`,
27//! `PersistReasoningFor`, `RefreshInstructions`), MCP lifecycle
28//! (`InitMcpServers`, `StopMcpServer`), local side-effects
29//! (`WriteImageToTemp`, `OpenInSystem`, `PullOllamaModel`,
30//! `SetTerminalTitle`, `DismissStatusAfter`). Cancellation flows
31//! through `Cmd::CancelScope(TurnId)` → the scope's
32//! `CancellationToken`.
33
34mod middleware;
35mod turn_scope;
36
37use std::collections::HashMap;
38use std::path::PathBuf;
39use std::sync::Arc;
40use std::time::Instant;
41
42use tokio::sync::mpsc;
43
44use crate::app::Config;
45use crate::domain::{
46    Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
47};
48use crate::models::{ModelError, TokenUsage};
49use crate::providers::ctx::{ExecContext, StreamContext};
50use crate::providers::model::ModelProvider;
51use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
52
53pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
54pub use turn_scope::TurnScope;
55
56#[cfg(not(test))]
57const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
58#[cfg(test)]
59const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
60
61/// Single channel back to the reducer. `EffectRunner` holds the
62/// sender; every spawned task clones this so it can emit `Msg` as
63/// work progresses. Bounded capacity applies natural backpressure —
64/// if the main loop can't keep up, the provider's streaming send
65/// `.await`s and the whole pipeline throttles.
66pub type MsgSender = mpsc::Sender<Msg>;
67
68/// Bounded channel capacity for the effect → reducer stream. 512 is
69/// generous — a single streaming chunk fits comfortably, and the
70/// main loop drains at ~60 Hz so backlog rarely grows. Bigger wastes
71/// RAM; smaller introduces spurious backpressure on bursty tool
72/// output.
73pub const MSG_CHANNEL_CAPACITY: usize = 512;
74
75/// The runner. One instance per process, constructed by
76/// `app::run` and consumed when the main loop exits.
77pub struct EffectRunner {
78    msg_tx: MsgSender,
79    /// Per-turn scopes. Populated lazily: the first `Cmd` bearing a
80    /// TurnId creates a scope; `Cmd::CancelScope` tears it down.
81    /// Empty (drained) scopes are reaped by `reap_empty_scopes`, which
82    /// runs at the top of every `dispatch` call so the map stays
83    /// bounded across long sessions (F12).
84    scopes: HashMap<TurnId, TurnScope>,
85    /// Detached work (saves, persists, MCP lifecycle) lives here.
86    /// This one set never gets cancelled piecemeal — shutdown drains
87    /// it during `EffectRunner::shutdown`.
88    detached: tokio::task::JoinSet<()>,
89    /// MCP manager handle is held elsewhere (`crate::mcp` has a
90    /// `OnceLock` for its global manager); we just note workdir so
91    /// handlers can construct absolute paths.
92    workdir: PathBuf,
93    /// Lazy provider registry. `CallModel` resolves through this.
94    /// Tests that don't care about real providers leave this `None`
95    /// and observe the fallback `UpstreamError` Msg; production
96    /// construction via `with_bindings` sets it.
97    providers: Option<Arc<ProviderFactory>>,
98    /// Shared tool registry. See `providers` — same optionality
99    /// rationale for unit tests.
100    tools: Option<Arc<ToolRegistry>>,
101    /// Durable runtime task that owns work launched by this runner.
102    task_id: Option<String>,
103    /// Interactive TUI runners write OSC 2 terminal-title updates.
104    /// Headless `mermaid run` must suppress them so stdout stays
105    /// machine-readable for JSON/markdown/text output modes.
106    terminal_title_enabled: bool,
107    /// Inline-approval broker. `Some` only for interactive TUI runs (set via
108    /// `with_interactive_approvals`); headless + child runners leave it `None`,
109    /// so the gate falls back to the out-of-band DB-approval flow.
110    approval: Option<crate::providers::ApprovalBroker>,
111}
112
113impl EffectRunner {
114    /// Create an unused runner. Pair with `msg_rx` from `channel()`.
115    pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
116        Self {
117            msg_tx,
118            scopes: HashMap::new(),
119            detached: tokio::task::JoinSet::new(),
120            workdir,
121            providers: None,
122            tools: None,
123            task_id: None,
124            terminal_title_enabled: true,
125            approval: None,
126        }
127    }
128
129    /// Enable inline approval prompts (interactive TUI only). The gate then
130    /// pauses gated tools and routes the user's decision through the
131    /// `ApprovalBroker` instead of writing an out-of-band DB approval row.
132    pub fn with_interactive_approvals(mut self) -> Self {
133        self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
134        self
135    }
136
137    /// Attach a durable runtime task id so tool runs, approvals,
138    /// checkpoints, compactions, and background processes can be linked.
139    pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
140        self.task_id = task_id;
141        self
142    }
143
144    /// Disable terminal-title writes for non-interactive callers.
145    pub fn without_terminal_title(mut self) -> Self {
146        self.terminal_title_enabled = false;
147        self
148    }
149
150    /// Attach provider + tool registries. Production wiring uses
151    /// this; unit tests that don't need real dispatch can skip.
152    /// Without bindings, `CallModel` / `ExecuteTool` emit well-
153    /// formed error Msgs so the reducer still transitions cleanly.
154    pub fn with_bindings(
155        mut self,
156        providers: Arc<ProviderFactory>,
157        tools: Arc<ToolRegistry>,
158    ) -> Self {
159        self.providers = Some(providers);
160        self.tools = Some(tools);
161        self
162    }
163
164    /// Pair-constructor: returns both the runner and the receiving
165    /// end of the Msg channel. Preferred for production wiring
166    /// because it keeps the channel capacity constant in one place.
167    pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
168        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
169        (Self::new(tx, workdir), rx)
170    }
171
172    /// Pair constructor that also wires the real provider factory +
173    /// tool registry. Used by `app::run_interactive`.
174    pub fn pair_with_bindings(
175        workdir: PathBuf,
176        config: Config,
177        tools: Arc<ToolRegistry>,
178    ) -> (Self, mpsc::Receiver<Msg>) {
179        let providers = Arc::new(ProviderFactory::new(config));
180        Self::pair_from(workdir, providers, tools)
181    }
182
183    /// Pair constructor that takes a pre-built `ProviderFactory`.
184    /// Used when the caller needs to share a `ProviderFactory` with
185    /// the `SubagentSpawner` so subagents can issue model calls
186    /// through the same cache.
187    pub fn pair_from(
188        workdir: PathBuf,
189        providers: Arc<ProviderFactory>,
190        tools: Arc<ToolRegistry>,
191    ) -> (Self, mpsc::Receiver<Msg>) {
192        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
193        (Self::new(tx, workdir).with_bindings(providers, tools), rx)
194    }
195
196    pub fn pair_from_with_task(
197        workdir: PathBuf,
198        providers: Arc<ProviderFactory>,
199        tools: Arc<ToolRegistry>,
200        task_id: Option<String>,
201    ) -> (Self, mpsc::Receiver<Msg>) {
202        let (runner, rx) = Self::pair_from(workdir, providers, tools);
203        (runner.with_task_id(task_id), rx)
204    }
205
206    /// Construct a runner that shares a pre-derived cancellation
207    /// token for its turn scopes. Used by `SubagentSpawner` so the
208    /// child runner's work aborts as soon as the parent's `ctx.token`
209    /// fires.
210    pub fn new_child(
211        msg_tx: MsgSender,
212        workdir: PathBuf,
213        providers: Arc<ProviderFactory>,
214        tools: Arc<ToolRegistry>,
215    ) -> Self {
216        Self::new(msg_tx, workdir).with_bindings(providers, tools)
217    }
218
219    /// Get or create the scope for a turn. Idempotent. The scope is
220    /// retained until `CancelScope` tears it down or it naturally
221    /// drains.
222    fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
223        self.scopes
224            .entry(turn)
225            .or_insert_with(|| TurnScope::new(turn))
226    }
227
228    /// Drop the scope for a turn, signalling cancellation to every
229    /// child first. Safe to call for non-existent turns.
230    ///
231    /// After the scope is cancelled, a detached task moves it off the
232    /// runner, drains its `JoinSet` (so child tasks unwind), then emits
233    /// `Msg::TurnCancelled(turn)` so the reducer can transition
234    /// `Cancelling → Idle`. Without this terminal event the TUI would
235    /// stick in `Cancelling` — the reducer has no other way to learn
236    /// that the abort fully landed.
237    fn drop_scope(&mut self, turn: TurnId) {
238        if let Some(mut scope) = self.scopes.remove(&turn) {
239            scope.cancel();
240            let tx = self.msg_tx.clone();
241            self.detached.spawn(async move {
242                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
243                    .await
244                    .is_err()
245                {
246                    tracing::warn!(
247                        turn = %turn,
248                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
249                        "cancel drain timed out; aborting remaining scoped tasks"
250                    );
251                }
252                let _ = tx.send(Msg::TurnCancelled(turn)).await;
253            });
254        }
255    }
256
257    /// Number of active per-turn scopes. Tests use this to observe
258    /// lifecycle without racing on internal state.
259    pub fn scope_count(&self) -> usize {
260        self.scopes.len()
261    }
262
263    /// F12: remove scope entries whose `JoinSet` is empty — every
264    /// child task has completed, so the scope is just an orphan key
265    /// in the map. Called at the top of `dispatch` so the map stays
266    /// bounded over long sessions. Cheap: one linear walk, no async.
267    ///
268    /// `JoinSet::is_empty` only returns true after completed tasks are
269    /// harvested via `join_next`/`try_join_next`, so we first drain
270    /// any ready completions per scope.
271    fn reap_empty_scopes(&mut self) {
272        self.scopes.retain(|_, scope| {
273            scope.drain_completed();
274            !scope.is_empty()
275        });
276    }
277
278    /// Route a single `Cmd` into the appropriate spawn + handler.
279    /// Returns immediately; handlers work asynchronously and emit
280    /// `Msg` back through the sender channel.
281    pub fn dispatch(&mut self, cmd: Cmd) {
282        // F12: reap any drained scopes before touching the map. Keeps
283        // `scope_count()` bounded as the session grows.
284        self.reap_empty_scopes();
285        tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
286
287        match cmd {
288            Cmd::CallModel { turn, mut request } => {
289                let tx = self.msg_tx.clone();
290                let providers = self.providers.clone();
291                // Enrich `request.tools` with every user-facing
292                // tool in the bound registry. The reducer has
293                // already populated MCP tools from `state.mcp`;
294                // built-ins come from the runner (which holds the
295                // registry). This keeps `ChatRequest.tools` the
296                // single source of truth for what the model sees.
297                if let Some(tools) = &self.tools {
298                    let mut enriched = tools.describe_all();
299                    enriched.append(&mut request.tools);
300                    request.tools = enriched;
301                }
302                let _ = crate::runtime::run_plugin_hooks(
303                    "prompt_submit",
304                    &serde_json::json!({
305                        "turn_id": turn.0,
306                        "model_id": request.model_id.clone(),
307                        "message_count": request.messages.len(),
308                        "tool_count": request.tools.len(),
309                    }),
310                );
311                let scope = self.scope_mut(turn);
312                let token = scope.token();
313                scope.spawn(async move {
314                    dispatch_call_model(tx, providers, turn, request, token).await;
315                });
316            },
317            Cmd::CompactConversation { turn, mut request } => {
318                let tx = self.msg_tx.clone();
319                let providers = self.providers.clone();
320                if let Some(tools) = &self.tools {
321                    let mut enriched = tools.describe_all();
322                    enriched.append(&mut request.chat.tools);
323                    request.chat.tools = enriched;
324                }
325                let scope = self.scope_mut(turn);
326                let token = scope.token();
327                scope.spawn(async move {
328                    dispatch_compact_conversation(tx, providers, turn, request, token).await;
329                });
330            },
331            Cmd::ExecuteTool {
332                turn,
333                call_id,
334                source,
335                model_id,
336                safety_mode,
337                intent,
338            } => {
339                let tx = self.msg_tx.clone();
340                let tools = self.tools.clone();
341                let workdir = self.workdir.clone();
342                // Pass the shared Config from ProviderFactory so
343                // subagents inherit it (F7). Falls back to
344                // Config::default() when providers aren't bound (unit
345                // tests without real wiring).
346                let config = self
347                    .providers
348                    .as_ref()
349                    .map(|p| Arc::new(p.config().clone()))
350                    .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
351                // Auto mode: build an LLM classifier to vet borderline
352                // actions. Only when a provider is bound (real wiring); the
353                // gate fails safe to "escalate" when it's `None`. The vet
354                // uses the configured classifier model, else the session model.
355                let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
356                    if safety_mode == crate::runtime::SafetyMode::Auto {
357                        self.providers.as_ref().map(|p| {
358                            let model = config
359                                .safety
360                                .auto_classifier_model
361                                .clone()
362                                .unwrap_or_else(|| model_id.clone());
363                            Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
364                                as Arc<dyn crate::providers::AutoClassifier>
365                        })
366                    } else {
367                        None
368                    };
369                let task_id = self.task_id.clone();
370                let approval = self.approval.clone();
371                let scope = self.scope_mut(turn);
372                let token = scope.token();
373                scope.spawn(async move {
374                    dispatch_execute_tool(
375                        tx,
376                        tools,
377                        workdir,
378                        turn,
379                        call_id,
380                        source,
381                        token,
382                        config,
383                        model_id,
384                        task_id,
385                        safety_mode,
386                        intent,
387                        classifier,
388                        approval,
389                    )
390                    .await;
391                });
392            },
393            Cmd::ResolveApproval { call_id, decision } => {
394                // Deliver the user's inline decision to the parked tool task.
395                // Not turn-scoped — fire-and-forget to the broker.
396                if let Some(broker) = &self.approval {
397                    broker.resolve(call_id, decision.into());
398                }
399            },
400            Cmd::CancelScope(turn) => {
401                self.drop_scope(turn);
402            },
403            Cmd::SaveConversation(history) => {
404                let tx = self.msg_tx.clone();
405                let workdir = self.workdir.clone();
406                self.detached.spawn(async move {
407                    if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
408                        && manager.save_conversation(&history).is_ok()
409                    {
410                        let _ = tx.send(Msg::SessionSaved).await;
411                    } else {
412                        tracing::warn!("SaveConversation: failed to write to disk");
413                    }
414                });
415            },
416            Cmd::SaveCompactionArchive {
417                archive,
418                record,
419                conversation,
420            } => {
421                let tx = self.msg_tx.clone();
422                let workdir = self.workdir.clone();
423                let task_id = self.task_id.clone();
424                self.detached.spawn(async move {
425                    let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
426                        return;
427                    };
428                    // Archive FIRST — it is the only durable copy of the
429                    // dropped messages. Only overwrite the (stripped)
430                    // conversation once the archive has persisted, so a
431                    // failed archive can never lose messages.
432                    match manager.save_compaction_archive(&archive) {
433                        Ok(path) => {
434                            if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
435                                let compaction_id = record.id.clone();
436                                let conversation_id = archive.conversation_id.clone();
437                                let _ =
438                                    store.compactions().create(crate::runtime::NewCompaction {
439                                        id: Some(record.id),
440                                        task_id: task_id.clone(),
441                                        session_id: Some(archive.conversation_id),
442                                        source_token_estimate: Some(record.before_tokens as i64),
443                                        summary_token_count: Some(record.summary_tokens as i64),
444                                        preserved_turns: Some(record.preserved_message_count as i64),
445                                        archive_path: Some(path.display().to_string()),
446                                        verification_status: Some("verified".to_string()),
447                                    });
448                                let _ = crate::runtime::run_plugin_hooks(
449                                    "compaction",
450                                    &serde_json::json!({
451                                        "id": compaction_id,
452                                        "task_id": task_id.clone(),
453                                        "session_id": conversation_id,
454                                        "archive_path": path.display().to_string(),
455                                    }),
456                                );
457                            }
458                            if manager.save_conversation(&conversation).is_ok() {
459                                let _ = tx.send(Msg::SessionSaved).await;
460                            } else {
461                                tracing::warn!(
462                                    "SaveCompactionArchive: archive persisted but conversation save failed"
463                                );
464                            }
465                        },
466                        Err(err) => {
467                            tracing::warn!(
468                                error = %err,
469                                "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
470                            );
471                        },
472                    }
473                });
474            },
475            Cmd::SaveProcess(process) => {
476                let task_id = self.task_id.clone();
477                self.detached.spawn(async move {
478                    let status = match process.status {
479                        crate::domain::ManagedProcessStatus::Running => {
480                            crate::runtime::ProcessStatus::Running
481                        },
482                        crate::domain::ManagedProcessStatus::Exited => {
483                            crate::runtime::ProcessStatus::Exited
484                        },
485                        crate::domain::ManagedProcessStatus::Unknown => {
486                            crate::runtime::ProcessStatus::Unknown
487                        },
488                    };
489                    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
490                        let _ = store.processes().upsert(crate::runtime::NewProcess {
491                            id: Some(process.id),
492                            task_id,
493                            pid: process.pid,
494                            command: process.command,
495                            cwd: process.cwd,
496                            log_path: Some(process.log_path),
497                            detected_url: process.detected_url,
498                            status,
499                            health: None,
500                        });
501                    }
502                });
503            },
504            Cmd::PersistLastModel(model) => {
505                self.detached.spawn(async move {
506                    let _ = crate::app::persist_last_model(&model);
507                });
508            },
509            Cmd::PersistReasoningFor { model_id, level } => {
510                self.detached.spawn(async move {
511                    let _ = crate::app::persist_reasoning_for_model(&model_id, level);
512                });
513            },
514            Cmd::RefreshInstructions => {
515                let tx = self.msg_tx.clone();
516                let workdir = self.workdir.clone();
517                self.detached.spawn(async move {
518                    let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
519                    let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
520                });
521            },
522            Cmd::RefreshMemory => {
523                let tx = self.msg_tx.clone();
524                let workdir = self.workdir.clone();
525                self.detached.spawn(async move {
526                    let cfg = crate::app::load_config().unwrap_or_default().memory;
527                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
528                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
529                });
530            },
531            Cmd::ListMemory => {
532                let tx = self.msg_tx.clone();
533                let workdir = self.workdir.clone();
534                self.detached.spawn(async move {
535                    let cfg = crate::app::load_config().unwrap_or_default().memory;
536                    let text = match crate::app::memory::load(&workdir, &cfg) {
537                        Some(mem) => mem.index,
538                        None => "No memories saved yet. Durable facts (yours or mine) show up here — use `/remember <fact>` or just ask me to remember something.".to_string(),
539                    };
540                    let _ = tx.send(Msg::RuntimeText(text)).await;
541                });
542            },
543            Cmd::RememberMemory { text } => {
544                let tx = self.msg_tx.clone();
545                let workdir = self.workdir.clone();
546                self.detached.spawn(async move {
547                    let cfg = crate::app::load_config().unwrap_or_default().memory;
548                    let name = memory_title_from_text(&text);
549                    let (status, kind) = match crate::app::memory::write_memory(
550                        &workdir,
551                        crate::app::memory::MemoryScope::ProjectPrivate,
552                        &name,
553                        &text,
554                        &[],
555                        &text,
556                    ) {
557                        Ok(_) => (
558                            format!("Remembered: {name}"),
559                            crate::domain::StatusKind::Info,
560                        ),
561                        Err(e) => (
562                            format!("Couldn't save memory: {e}"),
563                            crate::domain::StatusKind::Error,
564                        ),
565                    };
566                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
567                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
568                    let _ = tx
569                        .send(Msg::TransientStatus {
570                            text: status,
571                            kind,
572                            dismiss_ms: 3_000,
573                        })
574                        .await;
575                });
576            },
577            Cmd::ForgetMemory { id } => {
578                let tx = self.msg_tx.clone();
579                let workdir = self.workdir.clone();
580                self.detached.spawn(async move {
581                    let cfg = crate::app::load_config().unwrap_or_default().memory;
582                    let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
583                        Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
584                        Ok(None) => (
585                            format!("No memory named '{id}'"),
586                            crate::domain::StatusKind::Info,
587                        ),
588                        Err(e) => (
589                            format!("Couldn't forget memory: {e}"),
590                            crate::domain::StatusKind::Error,
591                        ),
592                    };
593                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
594                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
595                    let _ = tx
596                        .send(Msg::TransientStatus {
597                            text: status,
598                            kind,
599                            dismiss_ms: 3_000,
600                        })
601                        .await;
602                });
603            },
604            Cmd::ConsolidateMemory { model_id } => {
605                let tx = self.msg_tx.clone();
606                let workdir = self.workdir.clone();
607                let providers = self.providers.clone();
608                self.detached.spawn(async move {
609                    consolidate_memory(tx, providers, workdir, model_id).await;
610                });
611            },
612            Cmd::LoadConversation(id) => {
613                let tx = self.msg_tx.clone();
614                let workdir = self.workdir.clone();
615                self.detached.spawn(async move {
616                    match crate::session::ConversationManager::new(&workdir) {
617                        Ok(mgr) => match mgr.load_conversation(&id) {
618                            Ok(history) => {
619                                let _ = tx.send(Msg::ConversationLoaded(history)).await;
620                            },
621                            Err(e) => {
622                                tracing::warn!(id = %id, error = %e, "LoadConversation failed");
623                            },
624                        },
625                        Err(e) => {
626                            tracing::warn!(error = %e, "ConversationManager init failed");
627                        },
628                    }
629                });
630            },
631            Cmd::ListConversations => {
632                let tx = self.msg_tx.clone();
633                let workdir = self.workdir.clone();
634                self.detached.spawn(async move {
635                    let summaries = match crate::session::ConversationManager::new(&workdir) {
636                        Ok(mgr) => mgr
637                            .list_conversations()
638                            .unwrap_or_default()
639                            .into_iter()
640                            .map(|h| crate::domain::ConversationSummary {
641                                id: h.id.clone(),
642                                title: h.title.clone(),
643                                message_count: h.messages.len(),
644                                updated_at: h.updated_at.to_rfc3339(),
645                            })
646                            .collect(),
647                        Err(_) => Vec::new(),
648                    };
649                    let _ = tx.send(Msg::ConversationsListed(summaries)).await;
650                });
651            },
652            Cmd::ListRuntimeTasks { limit } => {
653                let tx = self.msg_tx.clone();
654                self.detached.spawn(async move {
655                    let tasks = crate::runtime::RuntimeClient::auto()
656                        .list_tasks(limit)
657                        .map(|read| read.value)
658                        .unwrap_or_default();
659                    let _ = tx.send(Msg::RuntimeTasksListed(tasks)).await;
660                });
661            },
662            Cmd::LoadRuntimeTask { id } => {
663                let tx = self.msg_tx.clone();
664                self.detached.spawn(async move {
665                    let (task, events) = crate::runtime::RuntimeClient::auto()
666                        .task_detail(&id)
667                        .map(|read| (Some(read.value.task), read.value.events))
668                        .unwrap_or((None, Vec::new()));
669                    let _ = tx.send(Msg::RuntimeTaskLoaded { task, events }).await;
670                });
671            },
672            Cmd::ListRuntimeProcesses { limit } => {
673                let tx = self.msg_tx.clone();
674                self.detached.spawn(async move {
675                    let processes = crate::runtime::RuntimeClient::auto()
676                        .list_processes(limit)
677                        .map(|read| read.value)
678                        .unwrap_or_default();
679                    let _ = tx.send(Msg::RuntimeProcessesListed(processes)).await;
680                });
681            },
682            Cmd::ShowRuntimeProcessLogs { id } => {
683                let tx = self.msg_tx.clone();
684                self.detached.spawn(async move {
685                    let text = crate::runtime::RuntimeClient::auto()
686                        .process_log(&id, None)
687                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
688                        .unwrap_or_else(|err| format!("Process log error: {}", err));
689                    let _ = tx.send(Msg::RuntimeText(text)).await;
690                });
691            },
692            Cmd::StopRuntimeProcess { id } => {
693                let tx = self.msg_tx.clone();
694                self.detached.spawn(async move {
695                    let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
696                        Ok(response) => Msg::TransientStatus {
697                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
698                            kind: crate::domain::StatusKind::Info,
699                            dismiss_ms: 3_000,
700                        },
701                        Err(err) => Msg::TransientStatus {
702                            text: format!("Process stop failed: {}", err),
703                            kind: crate::domain::StatusKind::Warn,
704                            dismiss_ms: 5_000,
705                        },
706                    };
707                    let _ = tx.send(msg).await;
708                });
709            },
710            Cmd::RestartRuntimeProcess { id } => {
711                let tx = self.msg_tx.clone();
712                self.detached.spawn(async move {
713                    let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
714                        Ok(response) => Msg::TransientStatus {
715                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
716                            kind: crate::domain::StatusKind::Info,
717                            dismiss_ms: 3_000,
718                        },
719                        Err(err) => Msg::TransientStatus {
720                            text: format!("Process restart failed: {}", err),
721                            kind: crate::domain::StatusKind::Warn,
722                            dismiss_ms: 5_000,
723                        },
724                    };
725                    let _ = tx.send(msg).await;
726                });
727            },
728            Cmd::OpenRuntimeTarget { target } => {
729                self.detached.spawn(async move {
730                    let resolved = crate::runtime::RuntimeService::open_default()
731                        .and_then(|service| service.resolve_open_target(&target))
732                        .unwrap_or(target);
733                    crate::utils::open_file(resolved);
734                });
735            },
736            Cmd::ShowRuntimePorts => {
737                let tx = self.msg_tx.clone();
738                self.detached.spawn(async move {
739                    let text = crate::runtime::RuntimeClient::auto()
740                        .ports()
741                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
742                        .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
743                    let _ = tx.send(Msg::RuntimeText(text)).await;
744                });
745            },
746            Cmd::ListRuntimeApprovals => {
747                let tx = self.msg_tx.clone();
748                self.detached.spawn(async move {
749                    let approvals = crate::runtime::RuntimeClient::auto()
750                        .list_approvals()
751                        .map(|read| read.value)
752                        .unwrap_or_default();
753                    let _ = tx.send(Msg::RuntimeApprovalsListed(approvals)).await;
754                });
755            },
756            Cmd::DecideRuntimeApproval { id, decision } => {
757                let tx = self.msg_tx.clone();
758                self.detached.spawn(async move {
759                    let result = if decision == "approved" {
760                        crate::runtime::RuntimeClient::auto().approve(&id)
761                    } else {
762                        crate::runtime::RuntimeClient::auto().deny(&id)
763                    };
764                    let msg = match result {
765                        Ok(result) => Msg::TransientStatus {
766                            text: if result.replayed {
767                                format!("Approval {} {}: {}", id, decision, result.summary)
768                            } else {
769                                format!("Approval {} {}", id, decision)
770                            },
771                            kind: crate::domain::StatusKind::Info,
772                            dismiss_ms: 3_000,
773                        },
774                        Err(err) => Msg::TransientStatus {
775                            text: format!("Approval update failed: {}", err),
776                            kind: crate::domain::StatusKind::Warn,
777                            dismiss_ms: 5_000,
778                        },
779                    };
780                    let _ = tx.send(msg).await;
781                });
782            },
783            Cmd::ListRuntimeCheckpoints { limit } => {
784                let tx = self.msg_tx.clone();
785                self.detached.spawn(async move {
786                    let checkpoints = crate::runtime::RuntimeClient::auto()
787                        .list_checkpoints(limit)
788                        .map(|read| read.value)
789                        .unwrap_or_default();
790                    let _ = tx.send(Msg::RuntimeCheckpointsListed(checkpoints)).await;
791                });
792            },
793            Cmd::ListRuntimePlugins => {
794                let tx = self.msg_tx.clone();
795                self.detached.spawn(async move {
796                    let plugins = crate::runtime::RuntimeClient::auto()
797                        .list_plugins()
798                        .map(|read| read.value)
799                        .unwrap_or_default();
800                    let _ = tx.send(Msg::RuntimePluginsListed(plugins)).await;
801                });
802            },
803            Cmd::UpdateRuntimeTaskStatus {
804                id,
805                status,
806                final_report,
807            } => {
808                let tx = self.msg_tx.clone();
809                self.detached.spawn(async move {
810                    let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
811                        store
812                            .tasks()
813                            .update_status(&id, status, final_report.as_deref())
814                    }) {
815                        Ok(()) => Msg::TransientStatus {
816                            text: format!("Task {} -> {}", id, status),
817                            kind: crate::domain::StatusKind::Info,
818                            dismiss_ms: 3_000,
819                        },
820                        Err(err) => Msg::TransientStatus {
821                            text: format!("Task update failed: {}", err),
822                            kind: crate::domain::StatusKind::Warn,
823                            dismiss_ms: 4_000,
824                        },
825                    };
826                    let _ = tx.send(msg).await;
827                });
828            },
829            Cmd::CreateRuntimeCheckpoint { paths } => {
830                let tx = self.msg_tx.clone();
831                let workdir = self.workdir.clone();
832                self.detached.spawn(async move {
833                    let pending_action = Some(serde_json::json!({
834                        "source": "tui",
835                        "command": "checkpoint",
836                    }));
837                    let msg =
838                        match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
839                            Ok(manifest) => Msg::TransientStatus {
840                                text: format!(
841                                    "Checkpoint {} created for {} path(s)",
842                                    manifest.id,
843                                    manifest.files.len()
844                                ),
845                                kind: crate::domain::StatusKind::Info,
846                                dismiss_ms: 4_000,
847                            },
848                            Err(err) => Msg::TransientStatus {
849                                text: format!("Checkpoint failed: {}", err),
850                                kind: crate::domain::StatusKind::Warn,
851                                dismiss_ms: 5_000,
852                            },
853                        };
854                    let _ = tx.send(msg).await;
855                });
856            },
857            Cmd::RestoreRuntimeCheckpoint { id } => {
858                let tx = self.msg_tx.clone();
859                self.detached.spawn(async move {
860                    let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
861                        Ok(result) => Msg::TransientStatus {
862                            text: format!(
863                                "Restored checkpoint {} ({} file(s)){}",
864                                result.checkpoint.id,
865                                result.checkpoint.files.len(),
866                                if result.checkpoint.pending_action.is_some() {
867                                    "; pending action available in checkpoint manifest"
868                                } else {
869                                    ""
870                                }
871                            ),
872                            kind: crate::domain::StatusKind::Info,
873                            dismiss_ms: 4_000,
874                        },
875                        Err(err) => Msg::TransientStatus {
876                            text: format!("Restore failed: {}", err),
877                            kind: crate::domain::StatusKind::Warn,
878                            dismiss_ms: 5_000,
879                        },
880                    };
881                    let _ = tx.send(msg).await;
882                });
883            },
884            Cmd::ShowRuntimeModelInfo { model } => {
885                let tx = self.msg_tx.clone();
886                self.detached.spawn(async move {
887                    let text = runtime_model_info_text(&model);
888                    let _ = tx.send(Msg::RuntimeText(text)).await;
889                });
890            },
891            Cmd::InitMcpServers(configs) => {
892                let tx = self.msg_tx.clone();
893                self.detached.spawn(async move {
894                    if configs.is_empty() {
895                        return;
896                    }
897                    crate::mcp::manager_ref::mark_init_started();
898                    let manager =
899                        std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
900                    // Emit a Ready or Errored per server based on
901                    // what came up. Zero-tool servers are still ready
902                    // if the manager has an active client for them.
903                    for (name, _cfg) in configs.iter() {
904                        let server_tools: Vec<crate::domain::McpToolSpec> = manager
905                            .get_all_tools()
906                            .iter()
907                            .filter(|(server, _)| server == name)
908                            .map(|(_, def)| crate::domain::McpToolSpec {
909                                name: def.name.clone(),
910                                description: def.description.clone(),
911                                input_schema: def.input_schema.clone(),
912                            })
913                            .collect();
914                        let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
915                        let _ = tx.send(msg).await;
916                    }
917                    crate::mcp::manager_ref::set_manager(manager);
918                    crate::mcp::manager_ref::mark_init_complete();
919                });
920            },
921            Cmd::StopMcpServer { name } => {
922                let tx = self.msg_tx.clone();
923                self.detached.spawn(async move {
924                    let _ = tx.send(Msg::McpServerStopped { name }).await;
925                });
926            },
927            Cmd::PullOllamaModel { model } => {
928                let tx = self.msg_tx.clone();
929                self.detached.spawn(async move {
930                    dispatch_pull_ollama_model(tx, model).await;
931                });
932            },
933            Cmd::OpenInSystem(path) => {
934                self.detached.spawn(async move {
935                    let _ = tokio::task::spawn_blocking(move || {
936                        crate::utils::open_file(&path);
937                    })
938                    .await;
939                });
940            },
941            Cmd::DismissStatusAfter { ms } => {
942                let tx = self.msg_tx.clone();
943                self.detached.spawn(async move {
944                    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
945                    let _ = tx.send(Msg::StatusDismiss).await;
946                });
947            },
948            Cmd::WriteImageToTemp {
949                path,
950                bytes,
951                format: _,
952            } => {
953                self.detached.spawn(async move {
954                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
955                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
956                    }
957                });
958            },
959            Cmd::ReadClipboard => {
960                let tx = self.msg_tx.clone();
961                self.detached.spawn(async move {
962                    dispatch_read_clipboard(tx).await;
963                });
964            },
965            Cmd::CopyToClipboard(text) => {
966                let tx = self.msg_tx.clone();
967                self.detached.spawn(async move {
968                    dispatch_copy_to_clipboard(text, tx).await;
969                });
970            },
971            Cmd::Exit => {
972                // The main loop observes `state.should_exit` after
973                // the reducer returns; the runner doesn't need to
974                // take any special action. Documented here for
975                // exhaustiveness.
976            },
977            Cmd::SetTerminalTitle(title) => {
978                if !self.terminal_title_enabled {
979                    return;
980                }
981                self.detached.spawn(async move {
982                    use std::io::Write;
983                    let seq = format!("\x1b]2;{}\x07", title);
984                    let mut stdout = std::io::stdout();
985                    let _ = stdout.write_all(seq.as_bytes());
986                    let _ = stdout.flush();
987                });
988            },
989        }
990    }
991
992    /// Async shutdown: cancel every scope, then wait for all spawned
993    /// work to drain. Bounded by 5 seconds — a hung task past that
994    /// gets aborted outright by `JoinSet::drop`.
995    pub async fn shutdown(mut self) {
996        for (id, scope) in self.scopes.iter() {
997            tracing::debug!(turn = %id, "shutdown: cancelling scope");
998            scope.cancel();
999        }
1000
1001        // Drain with a bounded timeout.
1002        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1003
1004        let drain = async {
1005            for (_, mut scope) in self.scopes.drain() {
1006                scope.drain().await;
1007            }
1008            while let Some(result) = self.detached.join_next().await {
1009                if let Err(e) = result
1010                    && !e.is_cancelled()
1011                {
1012                    tracing::warn!(error = %e, "shutdown: detached task panic");
1013                }
1014            }
1015        };
1016
1017        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1018    }
1019
1020    /// Test helper: clone the Msg sender so a test can synthesize a
1021    /// message as if it came from an effect handler.
1022    #[doc(hidden)]
1023    pub fn msg_sender(&self) -> MsgSender {
1024        self.msg_tx.clone()
1025    }
1026}
1027
1028/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1029/// cached) and streams its events onto the Msg channel. Without a
1030/// bound `ProviderFactory` (unit tests), emits a single
1031/// `UpstreamError` so the reducer ends the turn cleanly.
1032async fn dispatch_call_model(
1033    msg_tx: MsgSender,
1034    providers: Option<Arc<ProviderFactory>>,
1035    turn: TurnId,
1036    mut request: crate::domain::ChatRequest,
1037    token: tokio_util::sync::CancellationToken,
1038) {
1039    use crate::models::UserFacingError;
1040
1041    let Some(factory) = providers else {
1042        let error = UserFacingError {
1043            summary: "not wired".to_string(),
1044            message: "EffectRunner has no ProviderFactory bound".to_string(),
1045            suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1046            category: crate::models::ErrorCategory::Internal,
1047            recoverable: false,
1048        };
1049        let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1050        return;
1051    };
1052
1053    // Lazily resolve the provider for this model.
1054    let provider = match factory.resolve(&request.model_id).await {
1055        Ok(p) => p,
1056        Err(e) => {
1057            let error = classify_error_for_ui(&e);
1058            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1059            return;
1060        },
1061    };
1062    record_provider_capabilities(&request.model_id, provider.capabilities());
1063    if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1064        let _ = msg_tx
1065            .send(Msg::TransientStatus {
1066                text: format!(
1067                    "{} does not advertise tool support; Mermaid will send the turn without tools",
1068                    request.model_id
1069                ),
1070                kind: crate::domain::StatusKind::Warn,
1071                dismiss_ms: 6_000,
1072            })
1073            .await;
1074        request.tools.clear();
1075    }
1076
1077    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1078        crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1079    });
1080    let context_snapshot =
1081        crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1082    let _ = msg_tx
1083        .send(Msg::ContextUsageEstimated {
1084            turn,
1085            snapshot: context_snapshot.clone(),
1086        })
1087        .await;
1088
1089    let policy = CompactionPolicy::default();
1090    let mut compacted_before_stream = false;
1091    if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1092        let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1093        match run_compaction(
1094            Arc::clone(&provider),
1095            turn,
1096            compaction,
1097            context_snapshot.clone(),
1098            max_context_tokens,
1099            token.clone(),
1100        )
1101        .await
1102        {
1103            Ok(result) => {
1104                request.messages = result.replacement_messages.clone();
1105                compacted_before_stream = true;
1106                let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1107            },
1108            Err(err) => {
1109                let hard_limit =
1110                    crate::domain::context_exceeds_hard_limit(&context_snapshot, &request, policy);
1111                let _ = msg_tx
1112                    .send(Msg::CompactionFailed {
1113                        turn,
1114                        trigger: CompactionTrigger::AutoThreshold,
1115                        message: err.to_string(),
1116                        kind: if hard_limit {
1117                            crate::domain::StatusKind::Error
1118                        } else {
1119                            crate::domain::StatusKind::Warn
1120                        },
1121                    })
1122                    .await;
1123                if hard_limit {
1124                    let error = UserFacingError {
1125                        summary: "Context too large".to_string(),
1126                        message: format!(
1127                            "The next request needs {} tokens before response reserve, and automatic compaction failed: {}",
1128                            context_snapshot.used_tokens, err
1129                        ),
1130                        suggestion: "Run /compact with focus instructions, or /clear to start a fresh session.".to_string(),
1131                        category: crate::models::ErrorCategory::Config,
1132                        recoverable: true,
1133                    };
1134                    let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1135                    return;
1136                }
1137            },
1138        }
1139    }
1140
1141    // Build a StreamContext — provider writes typed events into the
1142    // internal sink; we relay each to the reducer as a Msg.
1143    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1144    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1145
1146    // Drain stream events into Msgs on a sibling task. Drops when
1147    // the sink closes (provider's final `Done` or cancel).
1148    let relay_tx = msg_tx.clone();
1149    let relay = tokio::spawn(async move {
1150        while let Some(event) = stream_rx.recv().await {
1151            let msg = match event {
1152                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1153                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1154                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1155                StreamEvent::ThinkingSignature(_) => continue, // folded into Done below
1156                StreamEvent::Done {
1157                    usage,
1158                    thinking_signature,
1159                } => Msg::StreamDone {
1160                    turn,
1161                    usage,
1162                    thinking_signature,
1163                },
1164            };
1165            if relay_tx.send(msg).await.is_err() {
1166                break;
1167            }
1168        }
1169    });
1170
1171    // Run the actual provider. On error, the relay will have
1172    // already emitted partial events; we follow with a single
1173    // UpstreamError to terminate the turn cleanly.
1174    //
1175    // `ModelError::Cancelled` is swallowed — the terminal
1176    // `Msg::TurnCancelled` is emitted from `drop_scope` after the
1177    // turn's `TurnScope` drains. Emitting `UpstreamError` here would
1178    // commit a "cancelled" message the user didn't ask to see.
1179    match provider.chat(request.clone(), ctx).await {
1180        Ok(_final_response) => {
1181            // Success — the final `Done` flowed through the sink.
1182        },
1183        Err(crate::models::ModelError::Cancelled) => {
1184            // Silent: `drop_scope` will emit `Msg::TurnCancelled`.
1185        },
1186        Err(e) => {
1187            let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1188            if retry_context_limit {
1189                let latest_snapshot =
1190                    crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1191                let compaction =
1192                    CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1193                match run_compaction(
1194                    Arc::clone(&provider),
1195                    turn,
1196                    compaction,
1197                    latest_snapshot,
1198                    max_context_tokens,
1199                    token.clone(),
1200                )
1201                .await
1202                {
1203                    Ok(result) => {
1204                        let mut retry_request = request;
1205                        retry_request.messages = result.replacement_messages.clone();
1206                        let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1207                        let _ = relay.await;
1208                        dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1209                            .await;
1210                        return;
1211                    },
1212                    Err(compact_err) => {
1213                        let _ = msg_tx
1214                            .send(Msg::CompactionFailed {
1215                                turn,
1216                                trigger: CompactionTrigger::ContextLimitRetry,
1217                                message: compact_err.to_string(),
1218                                kind: crate::domain::StatusKind::Error,
1219                            })
1220                            .await;
1221                    },
1222                }
1223            }
1224            let error = classify_error_for_ui(&e);
1225            run_provider_error_hook(&request.model_id, &error);
1226            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1227        },
1228    }
1229
1230    let _ = relay.await;
1231}
1232
1233async fn dispatch_provider_stream(
1234    msg_tx: MsgSender,
1235    provider: Arc<dyn ModelProvider>,
1236    turn: TurnId,
1237    request: crate::domain::ChatRequest,
1238    token: tokio_util::sync::CancellationToken,
1239) {
1240    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1241    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1242    let relay_tx = msg_tx.clone();
1243    let relay = tokio::spawn(async move {
1244        while let Some(event) = stream_rx.recv().await {
1245            let msg = match event {
1246                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1247                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1248                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1249                StreamEvent::ThinkingSignature(_) => continue,
1250                StreamEvent::Done {
1251                    usage,
1252                    thinking_signature,
1253                } => Msg::StreamDone {
1254                    turn,
1255                    usage,
1256                    thinking_signature,
1257                },
1258            };
1259            if relay_tx.send(msg).await.is_err() {
1260                break;
1261            }
1262        }
1263    });
1264
1265    let model_id = request.model_id.clone();
1266    match provider.chat(request, ctx).await {
1267        Ok(_) | Err(ModelError::Cancelled) => {},
1268        Err(e) => {
1269            let error = classify_error_for_ui(&e);
1270            run_provider_error_hook(&model_id, &error);
1271            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1272        },
1273    }
1274
1275    let _ = relay.await;
1276}
1277
1278fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1279    let _ = crate::runtime::run_plugin_hooks(
1280        "provider_error",
1281        &serde_json::json!({
1282            "model_id": model_id,
1283            "summary": &error.summary,
1284            "message": &error.message,
1285            "category": format!("{:?}", error.category),
1286            "recoverable": error.recoverable,
1287        }),
1288    );
1289}
1290
1291/// Derive a short title for a `/remember` memory from free-text input: the
1292/// first non-empty line, capped to ~8 words / 60 chars. `write_memory`
1293/// slugifies it into the filename.
1294fn memory_title_from_text(text: &str) -> String {
1295    let first = text
1296        .lines()
1297        .find(|l| !l.trim().is_empty())
1298        .unwrap_or("memory")
1299        .trim();
1300    let title: String = first
1301        .split_whitespace()
1302        .take(8)
1303        .collect::<Vec<_>>()
1304        .join(" ")
1305        .chars()
1306        .take(60)
1307        .collect();
1308    if title.trim().is_empty() {
1309        "memory".to_string()
1310    } else {
1311        title
1312    }
1313}
1314
1315const CONSOLIDATE_SYSTEM_PROMPT: &str = "You maintain a coding agent's durable memory: a set of atomic facts. Your only job is to find facts that are EXACT DUPLICATES or CLEARLY OBSOLETE/SUPERSEDED by another fact, and list their ids for pruning. Never prune facts that are merely related or similar but carry distinct information. Never rewrite or merge facts. When in doubt, keep. Reply with ONLY a JSON object: {\"prune\": [\"id1\", \"id2\"], \"reason\": \"one short sentence\"}. If nothing should be pruned, return an empty prune list.";
1316
1317#[derive(Debug)]
1318struct PrunePlan {
1319    prune: Vec<String>,
1320    reason: String,
1321}
1322
1323/// Extract a `{prune:[...], reason:""}` plan from a model response, tolerating
1324/// prose or code fences around the JSON object.
1325fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1326    let start = text.find('{')?;
1327    let end = text.rfind('}')?;
1328    if end < start {
1329        return None;
1330    }
1331    let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1332    let prune = json
1333        .get("prune")?
1334        .as_array()?
1335        .iter()
1336        .filter_map(|v| v.as_str().map(str::to_string))
1337        .collect();
1338    let reason = json
1339        .get("reason")
1340        .and_then(|v| v.as_str())
1341        .unwrap_or("")
1342        .to_string();
1343    Some(PrunePlan { prune, reason })
1344}
1345
1346/// `/consolidate-memory`: a one-shot model pass that names duplicate/obsolete
1347/// facts to prune (never rewrites — that's the anti-drift rule). The pruned
1348/// files are snapshotted into a checkpoint first, so the prune is reversible.
1349async fn consolidate_memory(
1350    tx: MsgSender,
1351    providers: Option<Arc<ProviderFactory>>,
1352    workdir: std::path::PathBuf,
1353    model_id: String,
1354) {
1355    let items = crate::app::memory::entries_with_bodies(&workdir);
1356    if items.len() < 2 {
1357        let _ = tx
1358            .send(Msg::RuntimeText(format!(
1359                "Nothing to consolidate — {} memor{} saved.",
1360                items.len(),
1361                if items.len() == 1 { "y" } else { "ies" }
1362            )))
1363            .await;
1364        return;
1365    }
1366    let Some(factory) = providers else {
1367        let _ = tx
1368            .send(Msg::RuntimeText(
1369                "Memory consolidation needs a model provider, which isn't bound in this session."
1370                    .to_string(),
1371            ))
1372            .await;
1373        return;
1374    };
1375
1376    let mut listing = String::new();
1377    for (entry, body) in &items {
1378        let id = entry
1379            .path
1380            .file_stem()
1381            .and_then(|s| s.to_str())
1382            .unwrap_or(entry.name.as_str());
1383        listing.push_str(&format!(
1384            "- id: {id}\n  scope: {}\n  description: {}\n  body: {}\n",
1385            entry.scope.as_str(),
1386            entry.description,
1387            body.replace('\n', " ").trim(),
1388        ));
1389    }
1390    let user = format!(
1391        "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1392        items.len(),
1393        listing
1394    );
1395    let request = crate::domain::ChatRequest {
1396        model_id: model_id.clone(),
1397        messages: vec![crate::models::ChatMessage::user(user)],
1398        system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1399        instructions: None,
1400        reasoning: crate::models::ReasoningLevel::None,
1401        temperature: 0.0,
1402        max_tokens: 1024,
1403        tools: Vec::new(),
1404    };
1405
1406    let provider = match factory.resolve(&model_id).await {
1407        Ok(p) => p,
1408        Err(e) => {
1409            let _ = tx
1410                .send(Msg::RuntimeText(format!(
1411                    "Memory consolidation failed: {e}"
1412                )))
1413                .await;
1414            return;
1415        },
1416    };
1417    let token = tokio_util::sync::CancellationToken::new();
1418    let text =
1419        match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1420            Ok((t, _)) => t,
1421            Err(e) => {
1422                let _ = tx
1423                    .send(Msg::RuntimeText(format!(
1424                        "Memory consolidation failed: {e}"
1425                    )))
1426                    .await;
1427                return;
1428            },
1429        };
1430
1431    let Some(plan) = parse_prune_plan(&text) else {
1432        let _ = tx
1433            .send(Msg::RuntimeText(
1434                "Memory consolidation: couldn't parse the model's plan; nothing changed."
1435                    .to_string(),
1436            ))
1437            .await;
1438        return;
1439    };
1440    if plan.prune.is_empty() {
1441        let reason = if plan.reason.is_empty() {
1442            String::new()
1443        } else {
1444            format!(" {}", plan.reason)
1445        };
1446        let _ = tx
1447            .send(Msg::RuntimeText(format!(
1448                "Memory consolidation: nothing to prune.{reason}"
1449            )))
1450            .await;
1451        return;
1452    }
1453
1454    // Snapshot the to-be-pruned files first so the prune is reversible.
1455    let paths: Vec<std::path::PathBuf> = plan
1456        .prune
1457        .iter()
1458        .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1459        .collect();
1460    if !paths.is_empty() {
1461        let _ = crate::runtime::create_checkpoint(
1462            &workdir,
1463            &paths,
1464            Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1465        );
1466    }
1467
1468    let mut pruned = Vec::new();
1469    for id in &plan.prune {
1470        if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1471            pruned.push(id.clone());
1472        }
1473    }
1474
1475    let cfg = crate::app::load_config().unwrap_or_default().memory;
1476    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1477    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1478
1479    let report = if pruned.is_empty() {
1480        "Memory consolidation: the model named facts to prune, but none matched existing memories."
1481            .to_string()
1482    } else {
1483        format!(
1484            "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1485            pruned.len(),
1486            if pruned.len() == 1 { "" } else { "s" },
1487            pruned.join(", "),
1488            if plan.reason.is_empty() {
1489                String::new()
1490            } else {
1491                format!(" Reason: {}.", plan.reason)
1492            },
1493        )
1494    };
1495    let _ = tx.send(Msg::RuntimeText(report)).await;
1496}
1497
1498async fn dispatch_compact_conversation(
1499    msg_tx: MsgSender,
1500    providers: Option<Arc<ProviderFactory>>,
1501    turn: TurnId,
1502    request: CompactionRequest,
1503    token: tokio_util::sync::CancellationToken,
1504) {
1505    let Some(factory) = providers else {
1506        let _ = msg_tx
1507            .send(Msg::CompactionFailed {
1508                turn,
1509                trigger: request.trigger,
1510                message: "EffectRunner has no ProviderFactory bound".to_string(),
1511                kind: crate::domain::StatusKind::Error,
1512            })
1513            .await;
1514        return;
1515    };
1516
1517    let provider = match factory.resolve(&request.chat.model_id).await {
1518        Ok(provider) => provider,
1519        Err(err) => {
1520            let _ = msg_tx
1521                .send(Msg::CompactionFailed {
1522                    turn,
1523                    trigger: request.trigger,
1524                    message: err.to_string(),
1525                    kind: crate::domain::StatusKind::Error,
1526                })
1527                .await;
1528            return;
1529        },
1530    };
1531
1532    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1533        crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1534    });
1535    let before_snapshot =
1536        crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1537
1538    let trigger = request.trigger;
1539    match run_compaction(
1540        provider,
1541        turn,
1542        request,
1543        before_snapshot,
1544        max_context_tokens,
1545        token,
1546    )
1547    .await
1548    {
1549        Ok(result) => {
1550            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1551        },
1552        Err(err) => {
1553            let _ = msg_tx
1554                .send(Msg::CompactionFailed {
1555                    turn,
1556                    trigger,
1557                    message: err.to_string(),
1558                    kind: crate::domain::StatusKind::Error,
1559                })
1560                .await;
1561        },
1562    }
1563}
1564
1565async fn run_compaction(
1566    provider: Arc<dyn ModelProvider>,
1567    turn: TurnId,
1568    request: CompactionRequest,
1569    before_snapshot: crate::domain::ContextUsageSnapshot,
1570    max_context_tokens: Option<usize>,
1571    token: tokio_util::sync::CancellationToken,
1572) -> Result<CompactionResult, ModelError> {
1573    let started = Instant::now();
1574    let prepared = crate::domain::prepare_compaction(&request, max_context_tokens)
1575        .map_err(|skip| ModelError::InvalidRequest(skip.to_string()))?;
1576
1577    let summary_request = crate::domain::build_summary_request(
1578        &request.chat,
1579        &prepared,
1580        request.instructions.as_deref(),
1581        request.policy,
1582    );
1583    let (draft, draft_usage) =
1584        collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1585            .await?;
1586    let draft_summary = crate::domain::normalize_summary(&draft);
1587    if draft_summary.trim().is_empty() {
1588        return Err(ModelError::InvalidRequest(
1589            "compaction produced an empty summary".to_string(),
1590        ));
1591    }
1592
1593    let verify_request = crate::domain::build_verification_request(
1594        &request.chat,
1595        &prepared,
1596        &draft_summary,
1597        request.instructions.as_deref(),
1598        request.policy,
1599    );
1600    let (final_summary, verify_usage, verified, verification_error) =
1601        match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1602            Ok((verified_text, verify_usage)) => {
1603                let verified_summary = crate::domain::normalize_summary(&verified_text);
1604                if verified_summary.trim().is_empty() {
1605                    (
1606                        draft_summary,
1607                        verify_usage,
1608                        false,
1609                        Some("verification returned an empty summary".to_string()),
1610                    )
1611                } else {
1612                    (verified_summary, verify_usage, true, None)
1613                }
1614            },
1615            Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1616            Err(err) => (draft_summary, None, false, Some(err.to_string())),
1617        };
1618
1619    let id = format!(
1620        "compact_{}",
1621        chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1622    );
1623    let mut record = crate::domain::CompactionRecord {
1624        id,
1625        trigger: request.trigger,
1626        created_at: chrono::Local::now(),
1627        before_tokens: before_snapshot.used_tokens,
1628        after_tokens: 0,
1629        archived_message_count: prepared.archived_messages.len(),
1630        preserved_message_count: prepared.preserved_messages.len(),
1631        summary_tokens: final_summary.len().div_ceil(4),
1632        duration_secs: started.elapsed().as_secs_f64(),
1633        verified,
1634        verification_error,
1635        focus: request.instructions.clone(),
1636        archive_path: None,
1637    };
1638
1639    let mut replacement =
1640        crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1641    let mut compacted_request = request.chat.clone();
1642    compacted_request.messages = replacement.clone();
1643    let mut after_snapshot =
1644        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1645    record.after_tokens = after_snapshot.used_tokens;
1646    record.duration_secs = started.elapsed().as_secs_f64();
1647    replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1648    compacted_request.messages = replacement.clone();
1649    after_snapshot =
1650        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1651    record.after_tokens = after_snapshot.used_tokens;
1652
1653    if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1654        return Err(ModelError::InvalidRequest(format!(
1655            "compaction did not reduce context ({} -> {} tokens)",
1656            before_snapshot.used_tokens, after_snapshot.used_tokens
1657        )));
1658    }
1659
1660    if crate::domain::context_exceeds_hard_limit(
1661        &after_snapshot,
1662        &compacted_request,
1663        request.policy,
1664    ) {
1665        return Err(ModelError::InvalidRequest(format!(
1666            "compacted context still exceeds response reserve ({} tokens used)",
1667            after_snapshot.used_tokens
1668        )));
1669    }
1670
1671    Ok(CompactionResult {
1672        record,
1673        replacement_messages: replacement,
1674        archived_messages: prepared.archived_messages,
1675        before_snapshot,
1676        after_snapshot,
1677        usage: crate::domain::combine_usage(draft_usage, verify_usage),
1678    })
1679}
1680
1681async fn collect_compaction_text(
1682    provider: Arc<dyn ModelProvider>,
1683    turn: TurnId,
1684    request: crate::domain::ChatRequest,
1685    token: tokio_util::sync::CancellationToken,
1686) -> Result<(String, Option<TokenUsage>), ModelError> {
1687    // Shared with the Auto-mode safety classifier — see
1688    // `crate::providers::model::collect_text`.
1689    crate::providers::model::collect_text(provider, turn, request, token).await
1690}
1691
1692fn record_provider_capabilities(
1693    model_id: &str,
1694    caps: &crate::providers::capabilities::Capabilities,
1695) {
1696    let (provider, model) = split_model_id(model_id);
1697    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1698        for (key, value) in [
1699            ("tools_support", caps.supports_tools.to_string()),
1700            ("vision_support", caps.supports_vision.to_string()),
1701            (
1702                "context_limit",
1703                caps.max_context_tokens
1704                    .map(|v| v.to_string())
1705                    .unwrap_or_else(|| "unknown".to_string()),
1706            ),
1707            (
1708                "reasoning_parameter_shape",
1709                format!("{:?}", caps.supports_reasoning),
1710            ),
1711            (
1712                "streaming_usage_available",
1713                "provider_dependent".to_string(),
1714            ),
1715            ("token_usage_field_shape", "normalized".to_string()),
1716        ] {
1717            let _ = store
1718                .provider_probes()
1719                .upsert(crate::runtime::NewProviderProbe {
1720                    provider: provider.clone(),
1721                    model_id: model.clone(),
1722                    capability_key: key.to_string(),
1723                    capability_value: value,
1724                    confidence: "verified".to_string(),
1725                    error: None,
1726                });
1727        }
1728    }
1729}
1730
1731fn split_model_id(model_id: &str) -> (String, String) {
1732    match model_id.split_once('/') {
1733        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
1734            (provider.to_ascii_lowercase(), model.to_string())
1735        },
1736        _ => ("ollama".to_string(), model_id.to_string()),
1737    }
1738}
1739
1740fn is_context_limit_error(error: &ModelError) -> bool {
1741    let text = error.to_string().to_lowercase();
1742    text.contains("context")
1743        && (text.contains("too large")
1744            || text.contains("exceed")
1745            || text.contains("maximum")
1746            || text.contains("token"))
1747}
1748
1749/// Dispatch an `ExecuteTool` command.
1750#[allow(clippy::too_many_arguments)]
1751async fn dispatch_execute_tool(
1752    msg_tx: MsgSender,
1753    tools: Option<Arc<ToolRegistry>>,
1754    workdir: PathBuf,
1755    turn: TurnId,
1756    call_id: crate::domain::ToolCallId,
1757    source: crate::models::tool_call::ToolCall,
1758    token: tokio_util::sync::CancellationToken,
1759    config: Arc<crate::app::Config>,
1760    model_id: String,
1761    task_id: Option<String>,
1762    safety_mode: crate::runtime::SafetyMode,
1763    intent: Option<String>,
1764    classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
1765    approval: Option<crate::providers::ApprovalBroker>,
1766) {
1767    let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
1768
1769    let Some(registry) = tools else {
1770        let _ = msg_tx
1771            .send(Msg::ToolFinished {
1772                turn,
1773                call_id,
1774                outcome: crate::domain::ToolOutcome::error(
1775                    "EffectRunner has no ToolRegistry bound",
1776                    0.0,
1777                ),
1778            })
1779            .await;
1780        return;
1781    };
1782
1783    // Route MCP-prefixed calls to the mcp proxy, which takes
1784    // {server_name, tool_name, arguments}. The raw model call has
1785    // those embedded in the function name and arguments respectively.
1786    let (tool_key, args) = if source.function.name.starts_with("mcp__") {
1787        let rest = &source.function.name[5..];
1788        if let Some((server, tool)) = rest.split_once("__") {
1789            (
1790                "mcp_proxy",
1791                serde_json::json!({
1792                    "server_name": server,
1793                    "tool_name": tool,
1794                    "arguments": source.function.arguments.clone(),
1795                }),
1796            )
1797        } else {
1798            let _ = msg_tx
1799                .send(Msg::ToolFinished {
1800                    turn,
1801                    call_id,
1802                    outcome: crate::domain::ToolOutcome::error(
1803                        format!("invalid MCP tool name: {}", source.function.name),
1804                        0.0,
1805                    ),
1806                })
1807                .await;
1808            return;
1809        }
1810    } else {
1811        (
1812            source.function.name.as_str(),
1813            source.function.arguments.clone(),
1814        )
1815    };
1816    let tool_run_id = start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args);
1817
1818    let Some(tool) = registry.get(tool_key) else {
1819        let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
1820        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
1821        let _ = msg_tx
1822            .send(Msg::ToolFinished {
1823                turn,
1824                call_id,
1825                outcome,
1826            })
1827            .await;
1828        return;
1829    };
1830
1831    // Bridge the tool's progress channel to `Msg::ToolProgress`.
1832    // A sibling task drains progress events while the tool runs.
1833    // The channel closes when `progress_tx` drops (when `ctx`
1834    // drops at the end of `tool.execute`), which terminates the
1835    // relay loop cleanly.
1836    let (progress_tx, mut progress_rx) = mpsc::channel(16);
1837    let relay_tx = msg_tx.clone();
1838    let progress_relay = tokio::spawn(async move {
1839        while let Some(event) = progress_rx.recv().await {
1840            if relay_tx
1841                .send(Msg::ToolProgress {
1842                    turn,
1843                    call_id,
1844                    event,
1845                })
1846                .await
1847                .is_err()
1848            {
1849                break;
1850            }
1851        }
1852    });
1853
1854    let ctx = ExecContext::new(
1855        token,
1856        progress_tx,
1857        call_id,
1858        turn,
1859        workdir,
1860        config,
1861        model_id,
1862        task_id,
1863        safety_mode,
1864        intent,
1865        classifier,
1866        approval,
1867    );
1868    let before_payload = serde_json::json!({
1869        "turn_id": turn.0,
1870        "call_id": call_id.0,
1871        "tool": tool_key,
1872    });
1873    let _ = crate::runtime::run_plugin_hooks("before_tool_use", &before_payload);
1874    let outcome = tool.execute(args, ctx).await;
1875    let after_payload = serde_json::json!({
1876        "turn_id": turn.0,
1877        "call_id": call_id.0,
1878        "tool": tool_key,
1879        "status": tool_status_label(outcome.status),
1880        "summary": &outcome.summary,
1881    });
1882    let _ = crate::runtime::run_plugin_hooks("after_tool_use", &after_payload);
1883    finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
1884    let _ = progress_relay.await;
1885    let _ = msg_tx
1886        .send(Msg::ToolFinished {
1887            turn,
1888            call_id,
1889            outcome,
1890        })
1891        .await;
1892}
1893
1894fn start_runtime_tool_run(
1895    task_id: Option<&str>,
1896    turn: TurnId,
1897    call_id: crate::domain::ToolCallId,
1898    tool_name: &str,
1899    args: &serde_json::Value,
1900) -> Option<String> {
1901    crate::runtime::RuntimeStore::open_default()
1902        .and_then(|store| {
1903            store.tool_runs().start(crate::runtime::NewToolRun {
1904                id: None,
1905                task_id: task_id.map(str::to_string),
1906                turn_id: Some(turn.0.to_string()),
1907                call_id: Some(call_id.0.to_string()),
1908                tool_name: tool_name.to_string(),
1909                args_json: serde_json::to_string(args).ok(),
1910            })
1911        })
1912        .map(|record| record.id)
1913        .ok()
1914}
1915
1916fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
1917    let Some(tool_run_id) = tool_run_id else {
1918        return;
1919    };
1920    let output_json = serde_json::to_string(&serde_json::json!({
1921        "status": tool_status_label(outcome.status),
1922        "summary": &outcome.summary,
1923        "model_content": &outcome.model_content,
1924        "error": &outcome.error,
1925        "metadata": &outcome.metadata,
1926        "artifacts": &outcome.artifacts,
1927        "duration_secs": outcome.duration_secs,
1928    }))
1929    .ok();
1930    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1931        let _ = store.tool_runs().finish(
1932            tool_run_id,
1933            tool_status_label(outcome.status),
1934            output_json.as_deref(),
1935        );
1936    }
1937}
1938
1939fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
1940    match status {
1941        crate::domain::ToolStatus::Success => "success",
1942        crate::domain::ToolStatus::Error => "error",
1943        crate::domain::ToolStatus::Cancelled => "cancelled",
1944    }
1945}
1946
1947fn runtime_model_info_text(model: &str) -> String {
1948    let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
1949    let mut lines = vec![
1950        format!("Model info: {}", model),
1951        format!("- provider: {}", snapshot.provider),
1952        format!("- model: {}", snapshot.model),
1953        format!("- supports tools: {}", snapshot.supports_tools),
1954        format!("- supports vision: {}", snapshot.supports_vision),
1955        format!("- reasoning: {}", snapshot.reasoning),
1956        format!(
1957            "- context limit: {}",
1958            snapshot
1959                .max_context_tokens
1960                .map(|value: usize| value.to_string())
1961                .unwrap_or_else(|| "unknown".to_string())
1962        ),
1963    ];
1964    if let Ok(store) = crate::runtime::RuntimeStore::open_default()
1965        && let Ok(probes) = store
1966            .provider_probes()
1967            .list(Some(&snapshot.provider), Some(&snapshot.model))
1968        && !probes.is_empty()
1969    {
1970        lines.push(String::new());
1971        lines.push("Cached provider reality records:".to_string());
1972        for probe in probes {
1973            lines.push(format!(
1974                "- {} = {} ({})",
1975                probe.capability_key, probe.capability_value, probe.confidence
1976            ));
1977        }
1978    }
1979    lines.join("\n")
1980}
1981
1982/// Spawn `ollama pull <model>` and stream its stdout lines as
1983/// `Msg::ModelPullProgress` status updates. Emits a final
1984/// `Msg::ModelPullFinished` on successful exit; on failure, emits a
1985/// single `ModelPullProgress` with the error text.
1986async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
1987    use tokio::io::{AsyncBufReadExt, BufReader};
1988    use tokio::process::Command;
1989
1990    let mut cmd = Command::new("ollama");
1991    cmd.arg("pull")
1992        .arg(&model)
1993        .stdin(std::process::Stdio::null())
1994        .stdout(std::process::Stdio::piped())
1995        .stderr(std::process::Stdio::piped())
1996        .kill_on_drop(true);
1997
1998    let mut child = match cmd.spawn() {
1999        Ok(c) => c,
2000        Err(e) => {
2001            let _ = tx
2002                .send(Msg::ModelPullProgress(format!(
2003                    "ollama pull failed to start: {}",
2004                    e
2005                )))
2006                .await;
2007            return;
2008        },
2009    };
2010
2011    if let Some(stdout) = child.stdout.take() {
2012        let tx_inner = tx.clone();
2013        tokio::spawn(async move {
2014            let mut reader = BufReader::new(stdout).lines();
2015            while let Ok(Some(line)) = reader.next_line().await {
2016                let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2017            }
2018        });
2019    }
2020
2021    match child.wait().await {
2022        Ok(status) if status.success() => {
2023            let _ = tx.send(Msg::ModelPullFinished { model }).await;
2024        },
2025        Ok(status) => {
2026            let _ = tx
2027                .send(Msg::ModelPullProgress(format!(
2028                    "ollama pull exited with status {}",
2029                    status.code().unwrap_or(-1)
2030                )))
2031                .await;
2032        },
2033        Err(e) => {
2034            let _ = tx
2035                .send(Msg::ModelPullProgress(format!(
2036                    "ollama pull wait error: {}",
2037                    e
2038                )))
2039                .await;
2040        },
2041    }
2042}
2043
2044fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2045    if started {
2046        Msg::McpServerReady {
2047            name: name.to_string(),
2048            tools,
2049        }
2050    } else {
2051        Msg::McpServerErrored {
2052            name: name.to_string(),
2053            reason: "server failed to start or initialize".to_string(),
2054        }
2055    }
2056}
2057
2058/// Read the system clipboard on a blocking thread and emit a `Msg`
2059/// back into the main loop. Image content wins when present; falls
2060/// back to text; empty or error surface as `Msg::TransientStatus` so
2061/// the user gets visible feedback (a silent no-op on Ctrl+V would be
2062/// confusing, especially on macOS where `osascript` can take ~300ms).
2063///
2064/// `tokio::task::spawn_blocking` is the right primitive: `clipboard::
2065/// has_image` / `read_image_bytes` / `read_text` shell out to xclip /
2066/// wl-paste / pngpaste / PowerShell, all of which block synchronously.
2067async fn dispatch_read_clipboard(tx: MsgSender) {
2068    use crate::domain::{Paste, StatusKind};
2069
2070    enum Outcome {
2071        Image { bytes: Vec<u8>, format: String },
2072        Text(String),
2073        Empty,
2074        Error(String),
2075    }
2076
2077    let outcome = tokio::task::spawn_blocking(|| {
2078        if crate::clipboard::has_image() {
2079            match crate::clipboard::read_image_bytes() {
2080                Ok((bytes, format)) => Outcome::Image { bytes, format },
2081                Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2082            }
2083        } else {
2084            match crate::clipboard::read_text() {
2085                Ok(t) if !t.is_empty() => Outcome::Text(t),
2086                Ok(_) => Outcome::Empty,
2087                Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2088            }
2089        }
2090    })
2091    .await
2092    .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2093
2094    let msg = match outcome {
2095        Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2096        Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2097        Outcome::Empty => Msg::TransientStatus {
2098            text: "Clipboard is empty".to_string(),
2099            kind: StatusKind::Info,
2100            dismiss_ms: 2_000,
2101        },
2102        Outcome::Error(text) => Msg::TransientStatus {
2103            text,
2104            kind: StatusKind::Warn,
2105            dismiss_ms: 4_000,
2106        },
2107    };
2108    let _ = tx.send(msg).await;
2109}
2110
2111/// Write text to the system clipboard on a blocking thread (the platform
2112/// tools shell out and block), then report the result via a transient status.
2113async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2114    use crate::domain::StatusKind;
2115
2116    let char_count = text.chars().count();
2117    let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2118        .await
2119        .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2120
2121    let msg = match result {
2122        Ok(()) => Msg::TransientStatus {
2123            text: format!("Copied {char_count} chars to clipboard"),
2124            kind: StatusKind::Info,
2125            dismiss_ms: 2_000,
2126        },
2127        Err(e) => Msg::TransientStatus {
2128            text: format!("Copy failed: {e}"),
2129            kind: StatusKind::Warn,
2130            dismiss_ms: 4_000,
2131        },
2132    };
2133    let _ = tx.send(msg).await;
2134}
2135
2136fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2137    use crate::models::{ErrorCategory, ModelError, UserFacingError};
2138    match e {
2139        ModelError::Backend(b) => UserFacingError {
2140            summary: "Backend error".to_string(),
2141            message: b.to_string(),
2142            suggestion: "Check the provider endpoint / API key.".to_string(),
2143            category: ErrorCategory::Connection,
2144            recoverable: true,
2145        },
2146        ModelError::Authentication(msg) => UserFacingError {
2147            summary: "Auth error".to_string(),
2148            message: msg.clone(),
2149            suggestion: "Set the env var the provider expects.".to_string(),
2150            category: ErrorCategory::Auth,
2151            recoverable: false,
2152        },
2153        ModelError::RateLimit { retry_after } => UserFacingError {
2154            summary: "Rate limit".to_string(),
2155            message: format!("retry after {:?}", retry_after),
2156            suggestion: "Wait and try again.".to_string(),
2157            category: ErrorCategory::Temporary,
2158            recoverable: true,
2159        },
2160        ModelError::StreamError(msg) => UserFacingError {
2161            summary: "Stream error".to_string(),
2162            message: msg.clone(),
2163            suggestion: "Retry the request.".to_string(),
2164            category: ErrorCategory::Connection,
2165            recoverable: true,
2166        },
2167        other => UserFacingError {
2168            summary: "Model error".to_string(),
2169            message: other.to_string(),
2170            suggestion: String::new(),
2171            category: ErrorCategory::Internal,
2172            recoverable: false,
2173        },
2174    }
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179    use super::*;
2180    use crate::domain::ToolCallId;
2181    use std::time::Duration;
2182
2183    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2184        EffectRunner::pair(PathBuf::from("/tmp"))
2185    }
2186
2187    #[test]
2188    fn parse_prune_plan_extracts_json_amid_prose() {
2189        let plan = parse_prune_plan(
2190            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2191        )
2192        .expect("should parse");
2193        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2194        assert_eq!(plan.reason, "dupes");
2195    }
2196
2197    #[test]
2198    fn parse_prune_plan_handles_empty_and_garbage() {
2199        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2200            .expect("empty plan parses");
2201        assert!(empty.prune.is_empty());
2202        assert!(parse_prune_plan("no json here").is_none());
2203    }
2204
2205    #[test]
2206    fn memory_title_from_text_is_short_and_nonempty() {
2207        assert_eq!(
2208            memory_title_from_text("prefer ripgrep over grep"),
2209            "prefer ripgrep over grep"
2210        );
2211        assert_eq!(memory_title_from_text("   "), "memory");
2212        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2213        assert!(long.split_whitespace().count() <= 8);
2214    }
2215
2216    #[tokio::test]
2217    async fn dispatch_exit_is_noop_on_runner_state() {
2218        let (mut r, _rx) = runner();
2219        r.dispatch(Cmd::Exit);
2220        assert_eq!(r.scope_count(), 0);
2221    }
2222
2223    #[tokio::test]
2224    async fn dispatch_save_emits_session_saved() {
2225        let (mut r, mut rx) = runner();
2226        r.dispatch(Cmd::SaveConversation(
2227            crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2228        ));
2229        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2230            .await
2231            .expect("sender emits")
2232            .expect("channel alive");
2233        assert!(matches!(msg, Msg::SessionSaved));
2234    }
2235
2236    #[tokio::test]
2237    async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2238        let (mut r, mut rx) = runner();
2239        let t0 = std::time::Instant::now();
2240        r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2241        let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2242            .await
2243            .expect("sender emits")
2244            .expect("channel alive");
2245        assert!(matches!(msg, Msg::StatusDismiss));
2246        assert!(t0.elapsed() >= Duration::from_millis(25));
2247    }
2248
2249    #[test]
2250    fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2251        let msg = mcp_startup_msg("empty", true, Vec::new());
2252        assert!(matches!(
2253            msg,
2254            Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2255        ));
2256    }
2257
2258    #[test]
2259    fn mcp_startup_msg_reports_unstarted_server_as_error() {
2260        let msg = mcp_startup_msg("bad", false, Vec::new());
2261        assert!(matches!(
2262            msg,
2263            Msg::McpServerErrored { name, reason }
2264                if name == "bad" && reason.contains("failed to start")
2265        ));
2266    }
2267
2268    #[tokio::test]
2269    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2270        let (mut r, mut rx) = runner();
2271        let turn = TurnId(77);
2272        {
2273            let scope = r.scope_mut(turn);
2274            scope.spawn(async {
2275                std::future::pending::<()>().await;
2276            });
2277        }
2278        assert_eq!(r.scope_count(), 1);
2279
2280        let start = std::time::Instant::now();
2281        r.dispatch(Cmd::CancelScope(turn));
2282        assert_eq!(r.scope_count(), 0);
2283        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2284            .await
2285            .expect("bounded cancel should emit terminal message")
2286            .expect("channel alive");
2287        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2288        assert!(
2289            start.elapsed() < Duration::from_millis(500),
2290            "cancel terminal message took {:?}",
2291            start.elapsed()
2292        );
2293    }
2294
2295    #[tokio::test]
2296    async fn dispatch_call_model_creates_scope() {
2297        let (mut r, _rx) = runner();
2298        let turn = TurnId(7);
2299        let request = crate::domain::ChatRequest {
2300            model_id: "test/m".to_string(),
2301            messages: vec![],
2302            system_prompt: String::new(),
2303            instructions: None,
2304            reasoning: crate::models::ReasoningLevel::Medium,
2305            temperature: 0.7,
2306            max_tokens: 4096,
2307            tools: vec![],
2308        };
2309        r.dispatch(Cmd::CallModel { turn, request });
2310        assert_eq!(r.scope_count(), 1);
2311    }
2312
2313    /// F12: after a spawned task completes (here via the
2314    /// no-ProviderFactory error path), the next `dispatch` call reaps
2315    /// the empty scope instead of leaving an orphan entry in the map.
2316    #[tokio::test]
2317    async fn empty_scopes_are_reaped_on_next_dispatch() {
2318        let (mut r, mut rx) = runner();
2319        let turn = TurnId(42);
2320        let request = crate::domain::ChatRequest {
2321            model_id: "test/m".to_string(),
2322            messages: vec![],
2323            system_prompt: String::new(),
2324            instructions: None,
2325            reasoning: crate::models::ReasoningLevel::Medium,
2326            temperature: 0.7,
2327            max_tokens: 4096,
2328            tools: vec![],
2329        };
2330        r.dispatch(Cmd::CallModel { turn, request });
2331        assert_eq!(r.scope_count(), 1);
2332
2333        // Runner has no provider bindings → dispatch_call_model hits
2334        // the "not wired" error path and emits UpstreamError, then the
2335        // spawned task returns. Drain that message so we know the task
2336        // ran to completion.
2337        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2338            .await
2339            .expect("upstream error arrived")
2340            .expect("channel alive");
2341        assert!(matches!(msg, Msg::UpstreamError { .. }));
2342
2343        // Give the JoinSet a tick to notice the task finished.
2344        tokio::task::yield_now().await;
2345
2346        // Any subsequent dispatch reaps the now-empty scope.
2347        r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2348        assert_eq!(
2349            r.scope_count(),
2350            0,
2351            "completed scope must be reaped on next dispatch"
2352        );
2353    }
2354
2355    #[tokio::test]
2356    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2357        let (mut r, mut rx) = runner();
2358        let turn = TurnId(7);
2359        let call_id = ToolCallId(1);
2360        let source = crate::models::tool_call::ToolCall {
2361            id: Some("c1".to_string()),
2362            function: crate::models::tool_call::FunctionCall {
2363                name: "read_file".to_string(),
2364                arguments: serde_json::json!({"path": "x"}),
2365            },
2366        };
2367        r.dispatch(Cmd::ExecuteTool {
2368            turn,
2369            call_id,
2370            source,
2371            model_id: "ollama/test".to_string(),
2372            safety_mode: crate::runtime::SafetyMode::Ask,
2373            intent: None,
2374        });
2375        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2376            .await
2377            .expect("some msg")
2378            .expect("channel alive");
2379        assert!(matches!(
2380            first,
2381            Msg::ToolStarted {
2382                turn: t,
2383                call_id: c,
2384            } if t == turn && c == call_id
2385        ));
2386    }
2387
2388    #[tokio::test]
2389    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2390        let (mut r, _rx) = runner();
2391        let turn = TurnId(9);
2392        r.dispatch(Cmd::CallModel {
2393            turn,
2394            request: crate::domain::ChatRequest {
2395                model_id: "m".to_string(),
2396                messages: vec![],
2397                system_prompt: String::new(),
2398                instructions: None,
2399                reasoning: crate::models::ReasoningLevel::Medium,
2400                temperature: 0.7,
2401                max_tokens: 4096,
2402                tools: vec![],
2403            },
2404        });
2405        assert_eq!(r.scope_count(), 1);
2406
2407        r.dispatch(Cmd::CancelScope(turn));
2408        assert_eq!(r.scope_count(), 0);
2409    }
2410
2411    #[tokio::test]
2412    async fn shutdown_drains_pending_saves() {
2413        let (mut r, _rx) = runner();
2414        for _ in 0..5 {
2415            r.dispatch(Cmd::SaveConversation(
2416                crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2417            ));
2418        }
2419        // Shutdown waits for all five to complete (should be instant).
2420        let start = std::time::Instant::now();
2421        r.shutdown().await;
2422        assert!(start.elapsed() < Duration::from_secs(2));
2423    }
2424}