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