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