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        // Best-effort preflight: if there's nothing to compact, proceed
1303        // un-compacted (the provider's own context limit is the real gate).
1304        if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1305            match run_compaction(
1306                Arc::clone(&provider),
1307                turn,
1308                compaction,
1309                prepared,
1310                context_snapshot.clone(),
1311                max_context_tokens,
1312                token.clone(),
1313            )
1314            .await
1315            {
1316                Ok(result) => {
1317                    request.messages = result.replacement_messages.clone();
1318                    compacted_before_stream = true;
1319                    let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1320                },
1321                Err(err) => {
1322                    // Auto-compaction is best-effort. If it can't reduce the
1323                    // context — the estimate is roughest exactly at the limit, so
1324                    // a large preserved tail can read `after >= before` — don't
1325                    // kill the turn. Log it, surface a soft warning, and proceed
1326                    // with the original request; the provider's own context limit
1327                    // is the real gate. (Manual `/compact` keeps its hard error
1328                    // via `run_compaction`'s reduction guard.)
1329                    if token.is_cancelled() {
1330                        return;
1331                    }
1332                    tracing::warn!(
1333                        turn = %turn,
1334                        error = %err,
1335                        "auto-compaction failed; proceeding with the un-compacted request",
1336                    );
1337                    let _ = msg_tx
1338                        .send(Msg::CompactionFailed {
1339                            turn,
1340                            trigger: CompactionTrigger::AutoThreshold,
1341                            message: err.to_string(),
1342                            kind: crate::domain::StatusKind::Warn,
1343                        })
1344                        .await;
1345                },
1346            }
1347        }
1348    }
1349
1350    // Build a StreamContext — provider writes typed events into the
1351    // internal sink; we relay each to the reducer as a Msg.
1352    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1353    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1354
1355    // Drain stream events into Msgs on a sibling task. Ends when the sink
1356    // closes (provider's final `Done` or completion) OR the turn token is
1357    // cancelled — `select!`ing on the token ties this relay to the turn's
1358    // structured cancellation so a cancel drops it within a tick instead of
1359    // waiting on the next event. (A separate task is required: the relay must
1360    // run concurrently with `provider.chat` for streaming backpressure.)
1361    let relay_tx = msg_tx.clone();
1362    let relay_token = token.clone();
1363    let relay = tokio::spawn(async move {
1364        loop {
1365            let event = tokio::select! {
1366                biased;
1367                _ = relay_token.cancelled() => break,
1368                ev = stream_rx.recv() => match ev {
1369                    Some(ev) => ev,
1370                    None => break,
1371                },
1372            };
1373            let msg = match event {
1374                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1375                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1376                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1377                StreamEvent::ThinkingSignature(_) => continue, // folded into Done below
1378                StreamEvent::Done {
1379                    usage,
1380                    thinking_signature,
1381                    stop_reason,
1382                } => Msg::StreamDone {
1383                    turn,
1384                    usage,
1385                    thinking_signature,
1386                    stop_reason,
1387                },
1388            };
1389            if relay_tx.send(msg).await.is_err() {
1390                break;
1391            }
1392        }
1393    });
1394
1395    // Run the actual provider. On error, the relay will have
1396    // already emitted partial events; we follow with a single
1397    // UpstreamError to terminate the turn cleanly.
1398    //
1399    // `ModelError::Cancelled` is swallowed — the terminal
1400    // `Msg::TurnCancelled` is emitted from `drop_scope` after the
1401    // turn's `TurnScope` drains. Emitting `UpstreamError` here would
1402    // commit a "cancelled" message the user didn't ask to see.
1403    let mut completed_ok = false;
1404    match provider.chat(request.clone(), ctx).await {
1405        Ok(_final_response) => {
1406            // Success — the final `Done` flowed through the sink.
1407            completed_ok = true;
1408        },
1409        Err(crate::models::ModelError::Cancelled) => {
1410            // Silent: `drop_scope` will emit `Msg::TurnCancelled`.
1411        },
1412        Err(e) => {
1413            let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1414            if retry_context_limit {
1415                let latest_snapshot =
1416                    crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1417                let compaction =
1418                    CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1419                // Only retry if there's something to compact; otherwise fall
1420                // through to surface the original context-limit error.
1421                if let Ok(prepared) =
1422                    crate::domain::prepare_compaction(&compaction, max_context_tokens)
1423                {
1424                    match run_compaction(
1425                        Arc::clone(&provider),
1426                        turn,
1427                        compaction,
1428                        prepared,
1429                        latest_snapshot,
1430                        max_context_tokens,
1431                        token.clone(),
1432                    )
1433                    .await
1434                    {
1435                        Ok(result) => {
1436                            let mut retry_request = request;
1437                            retry_request.messages = result.replacement_messages.clone();
1438                            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1439                            join_logged(relay, "stream_relay").await;
1440                            dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1441                                .await;
1442                            return;
1443                        },
1444                        Err(compact_err) => {
1445                            let _ = msg_tx
1446                                .send(Msg::CompactionFailed {
1447                                    turn,
1448                                    trigger: CompactionTrigger::ContextLimitRetry,
1449                                    message: compact_err.to_string(),
1450                                    kind: crate::domain::StatusKind::Error,
1451                                })
1452                                .await;
1453                        },
1454                    }
1455                }
1456            }
1457            let error = classify_error_for_ui(&e);
1458            run_provider_error_hook(&request.model_id, &error).await;
1459            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1460        },
1461    }
1462
1463    join_logged(relay, "stream_relay").await;
1464
1465    // Post-turn (success only): verify the model actually fit VRAM. Skipped when
1466    // the user allowed RAM offload (no warning possible) and a no-op for
1467    // non-Ollama providers (verify_placement returns None). Off the critical path
1468    // — StreamDone is already enqueued, so any warning renders after the answer.
1469    if completed_ok
1470        && request.ollama_allow_ram_offload != Some(true)
1471        && let Some(p) = provider.verify_placement(sizing.effective).await
1472    {
1473        tracing::debug!(
1474            size_vram_bytes = p.size_vram_bytes,
1475            total_bytes = p.total_bytes,
1476            offloaded = p.size_vram_bytes < p.total_bytes,
1477            suggested_num_ctx = ?p.suggested_num_ctx,
1478            "Ollama placement"
1479        );
1480        let _ = msg_tx
1481            .send(Msg::OllamaPlacementResolved {
1482                model_id: request.model_id.clone(),
1483                size_vram_bytes: p.size_vram_bytes,
1484                total_bytes: p.total_bytes,
1485                suggested_num_ctx: p.suggested_num_ctx,
1486            })
1487            .await;
1488    }
1489}
1490
1491async fn dispatch_provider_stream(
1492    msg_tx: MsgSender,
1493    provider: Arc<dyn ModelProvider>,
1494    turn: TurnId,
1495    request: crate::domain::ChatRequest,
1496    token: tokio_util::sync::CancellationToken,
1497) {
1498    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1499    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1500    let relay_tx = msg_tx.clone();
1501    let relay_token = token.clone();
1502    let relay = tokio::spawn(async move {
1503        loop {
1504            let event = tokio::select! {
1505                biased;
1506                _ = relay_token.cancelled() => break,
1507                ev = stream_rx.recv() => match ev {
1508                    Some(ev) => ev,
1509                    None => break,
1510                },
1511            };
1512            let msg = match event {
1513                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1514                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1515                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1516                StreamEvent::ThinkingSignature(_) => continue,
1517                StreamEvent::Done {
1518                    usage,
1519                    thinking_signature,
1520                    stop_reason,
1521                } => Msg::StreamDone {
1522                    turn,
1523                    usage,
1524                    thinking_signature,
1525                    stop_reason,
1526                },
1527            };
1528            if relay_tx.send(msg).await.is_err() {
1529                break;
1530            }
1531        }
1532    });
1533
1534    let model_id = request.model_id.clone();
1535    match provider.chat(request, ctx).await {
1536        Ok(_) | Err(ModelError::Cancelled) => {},
1537        Err(e) => {
1538            let error = classify_error_for_ui(&e);
1539            run_provider_error_hook(&model_id, &error).await;
1540            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1541        },
1542    }
1543
1544    join_logged(relay, "stream_relay").await;
1545}
1546
1547/// Run plugin hooks OFF the async executor. `run_plugin_hooks` is synchronous —
1548/// it spawns hook children and bounded-waits on them — so calling it inline
1549/// would block a tokio worker, or (on the `dispatch` path) the whole event loop.
1550/// `spawn_blocking` moves it to the blocking pool. Hooks are fire-and-forget
1551/// observers, so the result is dropped.
1552async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1553    let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1554        .await;
1555}
1556
1557async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1558    fire_plugin_hooks(
1559        "provider_error",
1560        serde_json::json!({
1561            "model_id": model_id,
1562            "summary": &error.summary,
1563            "message": &error.message,
1564            "category": format!("{:?}", error.category),
1565            "recoverable": error.recoverable,
1566        }),
1567    )
1568    .await;
1569}
1570
1571/// Derive a short title for a `/remember` memory from free-text input: the
1572/// first non-empty line, capped to ~8 words / 60 chars. `write_memory`
1573/// slugifies it into the filename.
1574fn memory_title_from_text(text: &str) -> String {
1575    let first = text
1576        .lines()
1577        .find(|l| !l.trim().is_empty())
1578        .unwrap_or("memory")
1579        .trim();
1580    let title: String = first
1581        .split_whitespace()
1582        .take(8)
1583        .collect::<Vec<_>>()
1584        .join(" ")
1585        .chars()
1586        .take(60)
1587        .collect();
1588    if title.trim().is_empty() {
1589        "memory".to_string()
1590    } else {
1591        title
1592    }
1593}
1594
1595const 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.";
1596
1597#[derive(Debug)]
1598struct PrunePlan {
1599    prune: Vec<String>,
1600    reason: String,
1601}
1602
1603/// Extract a `{prune:[...], reason:""}` plan from a model response, tolerating
1604/// prose or code fences around the JSON object.
1605fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1606    let start = text.find('{')?;
1607    let end = text.rfind('}')?;
1608    if end < start {
1609        return None;
1610    }
1611    let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1612    let prune = json
1613        .get("prune")?
1614        .as_array()?
1615        .iter()
1616        .filter_map(|v| v.as_str().map(str::to_string))
1617        .collect();
1618    let reason = json
1619        .get("reason")
1620        .and_then(|v| v.as_str())
1621        .unwrap_or("")
1622        .to_string();
1623    Some(PrunePlan { prune, reason })
1624}
1625
1626/// `/consolidate-memory`: a one-shot model pass that names duplicate/obsolete
1627/// facts to prune (never rewrites — that's the anti-drift rule). The pruned
1628/// files are snapshotted into a checkpoint first, so the prune is reversible.
1629async fn consolidate_memory(
1630    tx: MsgSender,
1631    providers: Option<Arc<ProviderFactory>>,
1632    workdir: std::path::PathBuf,
1633    model_id: String,
1634) {
1635    let items = crate::app::memory::entries_with_bodies(&workdir);
1636    if items.len() < 2 {
1637        let _ = tx
1638            .send(Msg::RuntimeText(format!(
1639                "Nothing to consolidate — {} memor{} saved.",
1640                items.len(),
1641                if items.len() == 1 { "y" } else { "ies" }
1642            )))
1643            .await;
1644        return;
1645    }
1646    let Some(factory) = providers else {
1647        let _ = tx
1648            .send(Msg::RuntimeText(
1649                "Memory consolidation needs a model provider, which isn't bound in this session."
1650                    .to_string(),
1651            ))
1652            .await;
1653        return;
1654    };
1655
1656    let mut listing = String::new();
1657    for (entry, body) in &items {
1658        let id = entry
1659            .path
1660            .file_stem()
1661            .and_then(|s| s.to_str())
1662            .unwrap_or(entry.name.as_str());
1663        listing.push_str(&format!(
1664            "- id: {id}\n  scope: {}\n  description: {}\n  body: {}\n",
1665            entry.scope.as_str(),
1666            entry.description,
1667            body.replace('\n', " ").trim(),
1668        ));
1669    }
1670    let user = format!(
1671        "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1672        items.len(),
1673        listing
1674    );
1675    let request = crate::domain::ChatRequest {
1676        model_id: model_id.clone(),
1677        messages: vec![crate::models::ChatMessage::user(user)],
1678        system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1679        instructions: None,
1680        reasoning: crate::models::ReasoningLevel::None,
1681        temperature: 0.0,
1682        max_tokens: 1024,
1683        tools: Vec::new(),
1684        ollama_num_ctx: None,
1685        ollama_allow_ram_offload: None,
1686    };
1687
1688    let provider = match factory.resolve(&model_id).await {
1689        Ok(p) => p,
1690        Err(e) => {
1691            let _ = tx
1692                .send(Msg::RuntimeText(format!(
1693                    "Memory consolidation failed: {e}"
1694                )))
1695                .await;
1696            return;
1697        },
1698    };
1699    let token = tokio_util::sync::CancellationToken::new();
1700    let text =
1701        match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1702            Ok((t, _)) => t,
1703            Err(e) => {
1704                let _ = tx
1705                    .send(Msg::RuntimeText(format!(
1706                        "Memory consolidation failed: {e}"
1707                    )))
1708                    .await;
1709                return;
1710            },
1711        };
1712
1713    let Some(plan) = parse_prune_plan(&text) else {
1714        let _ = tx
1715            .send(Msg::RuntimeText(
1716                "Memory consolidation: couldn't parse the model's plan; nothing changed."
1717                    .to_string(),
1718            ))
1719            .await;
1720        return;
1721    };
1722    if plan.prune.is_empty() {
1723        let reason = if plan.reason.is_empty() {
1724            String::new()
1725        } else {
1726            format!(" {}", plan.reason)
1727        };
1728        let _ = tx
1729            .send(Msg::RuntimeText(format!(
1730                "Memory consolidation: nothing to prune.{reason}"
1731            )))
1732            .await;
1733        return;
1734    }
1735
1736    // Snapshot the to-be-pruned files first so the prune is reversible.
1737    let paths: Vec<std::path::PathBuf> = plan
1738        .prune
1739        .iter()
1740        .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1741        .collect();
1742    if !paths.is_empty() {
1743        let _ = crate::runtime::create_checkpoint(
1744            &workdir,
1745            &paths,
1746            Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1747        );
1748    }
1749
1750    let mut pruned = Vec::new();
1751    for id in &plan.prune {
1752        if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1753            pruned.push(id.clone());
1754        }
1755    }
1756
1757    let cfg = crate::app::load_config().unwrap_or_default().memory;
1758    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1759    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1760
1761    let report = if pruned.is_empty() {
1762        "Memory consolidation: the model named facts to prune, but none matched existing memories."
1763            .to_string()
1764    } else {
1765        format!(
1766            "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1767            pruned.len(),
1768            if pruned.len() == 1 { "" } else { "s" },
1769            pruned.join(", "),
1770            if plan.reason.is_empty() {
1771                String::new()
1772            } else {
1773                format!(" Reason: {}.", plan.reason)
1774            },
1775        )
1776    };
1777    let _ = tx.send(Msg::RuntimeText(report)).await;
1778}
1779
1780async fn dispatch_compact_conversation(
1781    msg_tx: MsgSender,
1782    providers: Option<Arc<ProviderFactory>>,
1783    turn: TurnId,
1784    request: CompactionRequest,
1785    token: tokio_util::sync::CancellationToken,
1786) {
1787    let Some(factory) = providers else {
1788        let _ = msg_tx
1789            .send(Msg::CompactionFailed {
1790                turn,
1791                trigger: request.trigger,
1792                message: "EffectRunner has no ProviderFactory bound".to_string(),
1793                kind: crate::domain::StatusKind::Error,
1794            })
1795            .await;
1796        return;
1797    };
1798
1799    let provider = match factory.resolve(&request.chat.model_id).await {
1800        Ok(provider) => provider,
1801        Err(err) => {
1802            let _ = msg_tx
1803                .send(Msg::CompactionFailed {
1804                    turn,
1805                    trigger: request.trigger,
1806                    message: err.to_string(),
1807                    kind: crate::domain::StatusKind::Error,
1808                })
1809                .await;
1810            return;
1811        },
1812    };
1813
1814    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1815        crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1816    });
1817    let before_snapshot =
1818        crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1819
1820    let trigger = request.trigger;
1821    // A benign precondition (e.g. too little history to summarize) is a no-op, not
1822    // a failure — surface it as `Info` so the reducer shows a calm note instead of
1823    // a "Compaction failed: Invalid request" error. Real failures (model errors,
1824    // an empty/non-reducing summary) still flow through `run_compaction` as errors.
1825    let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
1826        Ok(prepared) => prepared,
1827        Err(skip) => {
1828            let _ = msg_tx
1829                .send(Msg::CompactionFailed {
1830                    turn,
1831                    trigger,
1832                    message: skip.to_string(),
1833                    kind: crate::domain::StatusKind::Info,
1834                })
1835                .await;
1836            return;
1837        },
1838    };
1839    match run_compaction(
1840        provider,
1841        turn,
1842        request,
1843        prepared,
1844        before_snapshot,
1845        max_context_tokens,
1846        token,
1847    )
1848    .await
1849    {
1850        Ok(result) => {
1851            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1852        },
1853        Err(err) => {
1854            let _ = msg_tx
1855                .send(Msg::CompactionFailed {
1856                    turn,
1857                    trigger,
1858                    message: err.to_string(),
1859                    kind: crate::domain::StatusKind::Error,
1860                })
1861                .await;
1862        },
1863    }
1864}
1865
1866async fn run_compaction(
1867    provider: Arc<dyn ModelProvider>,
1868    turn: TurnId,
1869    request: CompactionRequest,
1870    prepared: crate::domain::PreparedCompaction,
1871    before_snapshot: crate::domain::ContextUsageSnapshot,
1872    max_context_tokens: Option<usize>,
1873    token: tokio_util::sync::CancellationToken,
1874) -> Result<CompactionResult, ModelError> {
1875    let started = Instant::now();
1876
1877    let summary_request = crate::domain::build_summary_request(
1878        &request.chat,
1879        &prepared,
1880        request.instructions.as_deref(),
1881        request.policy,
1882    );
1883    let (draft, draft_usage) =
1884        collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1885            .await?;
1886    let draft_summary = crate::domain::normalize_summary(&draft);
1887    if draft_summary.trim().is_empty() {
1888        return Err(ModelError::InvalidRequest(
1889            "compaction produced an empty summary".to_string(),
1890        ));
1891    }
1892
1893    let verify_request = crate::domain::build_verification_request(
1894        &request.chat,
1895        &prepared,
1896        &draft_summary,
1897        request.instructions.as_deref(),
1898        request.policy,
1899    );
1900    let (final_summary, verify_usage, verified, verification_error) =
1901        match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1902            Ok((verified_text, verify_usage)) => {
1903                let verified_summary = crate::domain::normalize_summary(&verified_text);
1904                if verified_summary.trim().is_empty() {
1905                    (
1906                        draft_summary,
1907                        verify_usage,
1908                        false,
1909                        Some("verification returned an empty summary".to_string()),
1910                    )
1911                } else {
1912                    (verified_summary, verify_usage, true, None)
1913                }
1914            },
1915            Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1916            Err(err) => (draft_summary, None, false, Some(err.to_string())),
1917        };
1918
1919    let id = format!(
1920        "compact_{}",
1921        chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1922    );
1923    let mut record = crate::domain::CompactionRecord {
1924        id,
1925        trigger: request.trigger,
1926        created_at: chrono::Local::now(),
1927        before_tokens: before_snapshot.used_tokens,
1928        after_tokens: 0,
1929        archived_message_count: prepared.archived_messages.len(),
1930        preserved_message_count: prepared.preserved_messages.len(),
1931        summary_tokens: final_summary.len().div_ceil(4),
1932        duration_secs: started.elapsed().as_secs_f64(),
1933        verified,
1934        verification_error,
1935        focus: request.instructions.clone(),
1936        archive_path: None,
1937    };
1938
1939    let mut replacement =
1940        crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1941    let mut compacted_request = request.chat.clone();
1942    compacted_request.messages = replacement.clone();
1943    let mut after_snapshot =
1944        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1945    record.after_tokens = after_snapshot.used_tokens;
1946    record.duration_secs = started.elapsed().as_secs_f64();
1947    replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1948    compacted_request.messages = replacement.clone();
1949    after_snapshot =
1950        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1951    record.after_tokens = after_snapshot.used_tokens;
1952
1953    if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1954        return Err(ModelError::InvalidRequest(format!(
1955            "compaction did not reduce context ({} -> {} tokens)",
1956            before_snapshot.used_tokens, after_snapshot.used_tokens
1957        )));
1958    }
1959
1960    if crate::domain::context_exceeds_hard_limit(
1961        &after_snapshot,
1962        &compacted_request,
1963        request.policy,
1964    ) {
1965        return Err(ModelError::InvalidRequest(format!(
1966            "compacted context still exceeds response reserve ({} tokens used)",
1967            after_snapshot.used_tokens
1968        )));
1969    }
1970
1971    Ok(CompactionResult {
1972        record,
1973        replacement_messages: replacement,
1974        archived_messages: prepared.archived_messages,
1975        before_snapshot,
1976        after_snapshot,
1977        usage: crate::domain::combine_usage(draft_usage, verify_usage),
1978    })
1979}
1980
1981async fn collect_compaction_text(
1982    provider: Arc<dyn ModelProvider>,
1983    turn: TurnId,
1984    request: crate::domain::ChatRequest,
1985    token: tokio_util::sync::CancellationToken,
1986) -> Result<(String, Option<TokenUsage>), ModelError> {
1987    // Shared with the Auto-mode safety classifier — see
1988    // `crate::providers::model::collect_text`.
1989    crate::providers::model::collect_text(provider, turn, request, token).await
1990}
1991
1992fn record_provider_capabilities(
1993    model_id: &str,
1994    caps: &crate::providers::capabilities::Capabilities,
1995) {
1996    let (provider, model) = split_model_id(model_id);
1997    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1998        for (key, value) in [
1999            ("tools_support", caps.supports_tools.to_string()),
2000            ("vision_support", caps.supports_vision.to_string()),
2001            (
2002                "context_limit",
2003                caps.max_context_tokens
2004                    .map(|v| v.to_string())
2005                    .unwrap_or_else(|| "unknown".to_string()),
2006            ),
2007            (
2008                "reasoning_parameter_shape",
2009                format!("{:?}", caps.supports_reasoning),
2010            ),
2011            (
2012                "streaming_usage_available",
2013                "provider_dependent".to_string(),
2014            ),
2015            ("token_usage_field_shape", "normalized".to_string()),
2016        ] {
2017            let _ = store
2018                .provider_probes()
2019                .upsert(crate::runtime::NewProviderProbe {
2020                    provider: provider.clone(),
2021                    model_id: model.clone(),
2022                    capability_key: key.to_string(),
2023                    capability_value: value,
2024                    confidence: "verified".to_string(),
2025                    error: None,
2026                });
2027        }
2028    }
2029}
2030
2031fn split_model_id(model_id: &str) -> (String, String) {
2032    match model_id.split_once('/') {
2033        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2034            (provider.to_ascii_lowercase(), model.to_string())
2035        },
2036        _ => ("ollama".to_string(), model_id.to_string()),
2037    }
2038}
2039
2040fn is_context_limit_error(error: &ModelError) -> bool {
2041    let text = error.to_string().to_lowercase();
2042    text.contains("context")
2043        && (text.contains("too large")
2044            || text.contains("exceed")
2045            || text.contains("maximum")
2046            || text.contains("token"))
2047}
2048
2049/// Dispatch an `ExecuteTool` command.
2050#[allow(clippy::too_many_arguments)]
2051async fn dispatch_execute_tool(
2052    msg_tx: MsgSender,
2053    tools: Option<Arc<ToolRegistry>>,
2054    workdir: PathBuf,
2055    turn: TurnId,
2056    call_id: crate::domain::ToolCallId,
2057    source: crate::models::tool_call::ToolCall,
2058    token: tokio_util::sync::CancellationToken,
2059    background: tokio_util::sync::CancellationToken,
2060    config: Arc<crate::app::Config>,
2061    model_id: String,
2062    task_id: Option<String>,
2063    safety_mode: crate::runtime::SafetyMode,
2064    intent: Option<String>,
2065    classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2066    approval: Option<crate::providers::ApprovalBroker>,
2067) {
2068    let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2069
2070    let Some(registry) = tools else {
2071        let _ = msg_tx
2072            .send(Msg::ToolFinished {
2073                turn,
2074                call_id,
2075                outcome: crate::domain::ToolOutcome::error(
2076                    "EffectRunner has no ToolRegistry bound",
2077                    0.0,
2078                ),
2079            })
2080            .await;
2081        return;
2082    };
2083
2084    // Route MCP-prefixed calls to the mcp proxy, which takes
2085    // {server_name, tool_name, arguments}. The raw model call has
2086    // those embedded in the function name and arguments respectively.
2087    let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2088        let rest = &source.function.name[5..];
2089        if let Some((server, tool)) = rest.split_once("__") {
2090            (
2091                "mcp_proxy",
2092                serde_json::json!({
2093                    "server_name": server,
2094                    "tool_name": tool,
2095                    "arguments": source.function.arguments.clone(),
2096                }),
2097            )
2098        } else {
2099            let _ = msg_tx
2100                .send(Msg::ToolFinished {
2101                    turn,
2102                    call_id,
2103                    outcome: crate::domain::ToolOutcome::error(
2104                        format!("invalid MCP tool name: {}", source.function.name),
2105                        0.0,
2106                    ),
2107                })
2108                .await;
2109            return;
2110        }
2111    } else {
2112        (
2113            source.function.name.as_str(),
2114            source.function.arguments.clone(),
2115        )
2116    };
2117    let tool_run_id =
2118        start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2119
2120    let Some(tool) = registry.get(tool_key) else {
2121        let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2122        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2123        let _ = msg_tx
2124            .send(Msg::ToolFinished {
2125                turn,
2126                call_id,
2127                outcome,
2128            })
2129            .await;
2130        return;
2131    };
2132
2133    // Bridge the tool's progress channel to `Msg::ToolProgress`.
2134    // A sibling task drains progress events while the tool runs.
2135    // The channel closes when `progress_tx` drops (when `ctx`
2136    // drops at the end of `tool.execute`), which terminates the
2137    // relay loop cleanly.
2138    let (progress_tx, mut progress_rx) = mpsc::channel(16);
2139    let relay_tx = msg_tx.clone();
2140    let relay_token = token.clone();
2141    let progress_relay = tokio::spawn(async move {
2142        loop {
2143            let event = tokio::select! {
2144                biased;
2145                _ = relay_token.cancelled() => break,
2146                ev = progress_rx.recv() => match ev {
2147                    Some(ev) => ev,
2148                    None => break,
2149                },
2150            };
2151            if relay_tx
2152                .send(Msg::ToolProgress {
2153                    turn,
2154                    call_id,
2155                    event,
2156                })
2157                .await
2158                .is_err()
2159            {
2160                break;
2161            }
2162        }
2163    });
2164
2165    let mut ctx = ExecContext::new(
2166        token,
2167        progress_tx,
2168        call_id,
2169        turn,
2170        workdir,
2171        config,
2172        model_id,
2173        task_id,
2174        safety_mode,
2175        intent,
2176        classifier,
2177        approval,
2178    );
2179    ctx.background = background;
2180    let before_payload = serde_json::json!({
2181        "turn_id": turn.0,
2182        "call_id": call_id.0,
2183        "tool": tool_key,
2184    });
2185    fire_plugin_hooks("before_tool_use", before_payload).await;
2186    let outcome = tool.execute(args, ctx).await;
2187    let after_payload = serde_json::json!({
2188        "turn_id": turn.0,
2189        "call_id": call_id.0,
2190        "tool": tool_key,
2191        "status": tool_status_label(outcome.status),
2192        "summary": &outcome.summary,
2193    });
2194    fire_plugin_hooks("after_tool_use", after_payload).await;
2195    finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2196    join_logged(progress_relay, "tool_progress_relay").await;
2197    let _ = msg_tx
2198        .send(Msg::ToolFinished {
2199            turn,
2200            call_id,
2201            outcome,
2202        })
2203        .await;
2204}
2205
2206async fn start_runtime_tool_run(
2207    task_id: Option<&str>,
2208    turn: TurnId,
2209    call_id: crate::domain::ToolCallId,
2210    tool_name: &str,
2211    args: &serde_json::Value,
2212) -> Option<String> {
2213    // Synchronous rusqlite write on the hot tool-execution path — offload it to
2214    // the blocking pool. The id is needed by `finish`, so we await the result
2215    // (unlike `finish`, which is fire-and-forget) (#39).
2216    let task_id = task_id.map(str::to_string);
2217    let tool_name = tool_name.to_string();
2218    let args_json = serde_json::to_string(args).ok();
2219    tokio::task::spawn_blocking(move || {
2220        crate::runtime::RuntimeStore::open_default()
2221            .and_then(|store| {
2222                store.tool_runs().start(crate::runtime::NewToolRun {
2223                    id: None,
2224                    task_id,
2225                    turn_id: Some(turn.0.to_string()),
2226                    call_id: Some(call_id.0.to_string()),
2227                    tool_name,
2228                    args_json,
2229                })
2230            })
2231            .map(|record| record.id)
2232            .ok()
2233    })
2234    .await
2235    .ok()
2236    .flatten()
2237}
2238
2239fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2240    let Some(tool_run_id) = tool_run_id else {
2241        return;
2242    };
2243    let tool_run_id = tool_run_id.to_string();
2244    let status = tool_status_label(outcome.status).to_string();
2245    let output_json = serde_json::to_string(&serde_json::json!({
2246        "status": tool_status_label(outcome.status),
2247        "summary": &outcome.summary,
2248        "model_content": &outcome.model_content,
2249        "error": &outcome.error,
2250        "metadata": &outcome.metadata,
2251        "artifacts": &outcome.artifacts,
2252        "duration_secs": outcome.duration_secs,
2253    }))
2254    .ok();
2255    // Fire-and-forget telemetry write on the blocking pool — don't stall the
2256    // tool-finish path waiting on rusqlite (#39).
2257    tokio::task::spawn_blocking(move || {
2258        if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2259            let _ = store
2260                .tool_runs()
2261                .finish(&tool_run_id, &status, output_json.as_deref());
2262        }
2263    });
2264}
2265
2266fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2267    match status {
2268        crate::domain::ToolStatus::Success => "success",
2269        crate::domain::ToolStatus::Error => "error",
2270        crate::domain::ToolStatus::Cancelled => "cancelled",
2271    }
2272}
2273
2274fn runtime_model_info_text(model: &str) -> String {
2275    let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2276    let mut lines = vec![
2277        format!("Model info: {}", model),
2278        format!("- provider: {}", snapshot.provider),
2279        format!("- model: {}", snapshot.model),
2280        format!("- supports tools: {}", snapshot.supports_tools),
2281        format!("- supports vision: {}", snapshot.supports_vision),
2282        format!("- reasoning: {}", snapshot.reasoning),
2283        format!(
2284            "- context limit: {}",
2285            snapshot
2286                .max_context_tokens
2287                .map(|value: usize| value.to_string())
2288                .unwrap_or_else(|| "unknown".to_string())
2289        ),
2290    ];
2291    if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2292        && let Ok(probes) = store
2293            .provider_probes()
2294            .list(Some(&snapshot.provider), Some(&snapshot.model))
2295        && !probes.is_empty()
2296    {
2297        lines.push(String::new());
2298        lines.push("Cached provider reality records:".to_string());
2299        for probe in probes {
2300            lines.push(format!(
2301                "- {} = {} ({})",
2302                probe.capability_key, probe.capability_value, probe.confidence
2303            ));
2304        }
2305    }
2306    lines.join("\n")
2307}
2308
2309/// Spawn `ollama pull <model>` and stream its stdout lines as
2310/// `Msg::ModelPullProgress` status updates. Emits a final
2311/// `Msg::ModelPullFinished` on successful exit; on failure, emits a
2312/// single `ModelPullProgress` with the error text.
2313async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2314    use tokio::io::{AsyncBufReadExt, BufReader};
2315    use tokio::process::Command;
2316
2317    let mut cmd = Command::new("ollama");
2318    cmd.arg("pull")
2319        .arg(&model)
2320        .stdin(std::process::Stdio::null())
2321        .stdout(std::process::Stdio::piped())
2322        .stderr(std::process::Stdio::piped())
2323        .kill_on_drop(true);
2324
2325    let mut child = match cmd.spawn() {
2326        Ok(c) => c,
2327        Err(e) => {
2328            let _ = tx
2329                .send(Msg::ModelPullProgress(format!(
2330                    "ollama pull failed to start: {}",
2331                    e
2332                )))
2333                .await;
2334            return;
2335        },
2336    };
2337
2338    // Capture the reader's handle instead of orphaning it: the child's stdout
2339    // closes when it exits, so this task finishes right after `child.wait`
2340    // below — we join it there so a panic is logged, not silently lost (#60).
2341    let reader_handle = child.stdout.take().map(|stdout| {
2342        let tx_inner = tx.clone();
2343        tokio::spawn(async move {
2344            let mut reader = BufReader::new(stdout).lines();
2345            while let Ok(Some(line)) = reader.next_line().await {
2346                let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2347            }
2348        })
2349    });
2350
2351    match child.wait().await {
2352        Ok(status) if status.success() => {
2353            let _ = tx.send(Msg::ModelPullFinished { model }).await;
2354        },
2355        Ok(status) => {
2356            let _ = tx
2357                .send(Msg::ModelPullProgress(format!(
2358                    "ollama pull exited with status {}",
2359                    status.code().unwrap_or(-1)
2360                )))
2361                .await;
2362        },
2363        Err(e) => {
2364            let _ = tx
2365                .send(Msg::ModelPullProgress(format!(
2366                    "ollama pull wait error: {}",
2367                    e
2368                )))
2369                .await;
2370        },
2371    }
2372
2373    // The child has exited; its stdout is closed, so the reader is finishing.
2374    // Join it (logging a panic) so it isn't left orphaned (#60).
2375    if let Some(handle) = reader_handle {
2376        join_logged(handle, "ollama_pull_reader").await;
2377    }
2378}
2379
2380fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2381    if started {
2382        Msg::McpServerReady {
2383            name: name.to_string(),
2384            tools,
2385        }
2386    } else {
2387        Msg::McpServerErrored {
2388            name: name.to_string(),
2389            reason: "server failed to start or initialize".to_string(),
2390        }
2391    }
2392}
2393
2394/// Read the system clipboard on a blocking thread and emit a `Msg`
2395/// back into the main loop. Image content wins when present; falls
2396/// back to text; empty or error surface as `Msg::TransientStatus` so
2397/// the user gets visible feedback (a silent no-op on Ctrl+V would be
2398/// confusing, especially on macOS where `osascript` can take ~300ms).
2399///
2400/// `tokio::task::spawn_blocking` is the right primitive: `clipboard::
2401/// has_image` / `read_image_bytes` / `read_text` shell out to xclip /
2402/// wl-paste / pngpaste / PowerShell, all of which block synchronously.
2403async fn dispatch_read_clipboard(tx: MsgSender) {
2404    use crate::domain::{Paste, StatusKind};
2405
2406    enum Outcome {
2407        Image { bytes: Vec<u8>, format: String },
2408        Text(String),
2409        Empty,
2410        Error(String),
2411    }
2412
2413    let outcome = tokio::task::spawn_blocking(|| {
2414        if crate::clipboard::has_image() {
2415            match crate::clipboard::read_image_bytes() {
2416                Ok((bytes, format)) => Outcome::Image { bytes, format },
2417                Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2418            }
2419        } else {
2420            match crate::clipboard::read_text() {
2421                Ok(t) if !t.is_empty() => Outcome::Text(t),
2422                Ok(_) => Outcome::Empty,
2423                Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2424            }
2425        }
2426    })
2427    .await
2428    .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2429
2430    let msg = match outcome {
2431        Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2432        Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2433        Outcome::Empty => Msg::TransientStatus {
2434            text: "Clipboard is empty".to_string(),
2435            kind: StatusKind::Info,
2436            dismiss_ms: 2_000,
2437        },
2438        Outcome::Error(text) => Msg::TransientStatus {
2439            text,
2440            kind: StatusKind::Warn,
2441            dismiss_ms: 4_000,
2442        },
2443    };
2444    let _ = tx.send(msg).await;
2445}
2446
2447/// Write text to the system clipboard on a blocking thread (the platform
2448/// tools shell out and block), then report the result via a transient status.
2449async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2450    use crate::domain::StatusKind;
2451
2452    let char_count = text.chars().count();
2453    let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2454        .await
2455        .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2456
2457    let msg = match result {
2458        Ok(()) => Msg::TransientStatus {
2459            text: format!("Copied {char_count} chars to clipboard"),
2460            kind: StatusKind::Info,
2461            dismiss_ms: 2_000,
2462        },
2463        Err(e) => Msg::TransientStatus {
2464            text: format!("Copy failed: {e}"),
2465            kind: StatusKind::Warn,
2466            dismiss_ms: 4_000,
2467        },
2468    };
2469    let _ = tx.send(msg).await;
2470}
2471
2472fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2473    use crate::models::{ErrorCategory, ModelError, UserFacingError};
2474    match e {
2475        ModelError::Backend(b) => UserFacingError {
2476            summary: "Backend error".to_string(),
2477            message: b.to_string(),
2478            suggestion: "Check the provider endpoint / API key.".to_string(),
2479            category: ErrorCategory::Connection,
2480            recoverable: true,
2481        },
2482        ModelError::Authentication(msg) => UserFacingError {
2483            summary: "Auth error".to_string(),
2484            message: msg.clone(),
2485            suggestion: "Set the env var the provider expects.".to_string(),
2486            category: ErrorCategory::Auth,
2487            recoverable: false,
2488        },
2489        ModelError::RateLimit { retry_after } => UserFacingError {
2490            summary: "Rate limit".to_string(),
2491            message: format!("retry after {:?}", retry_after),
2492            suggestion: "Wait and try again.".to_string(),
2493            category: ErrorCategory::Temporary,
2494            recoverable: true,
2495        },
2496        ModelError::StreamError(msg) => UserFacingError {
2497            summary: "Stream error".to_string(),
2498            message: msg.clone(),
2499            suggestion: "Retry the request.".to_string(),
2500            category: ErrorCategory::Connection,
2501            recoverable: true,
2502        },
2503        other => UserFacingError {
2504            summary: "Model error".to_string(),
2505            message: other.to_string(),
2506            suggestion: String::new(),
2507            category: ErrorCategory::Internal,
2508            recoverable: false,
2509        },
2510    }
2511}
2512
2513#[cfg(test)]
2514mod tests {
2515    use super::*;
2516    use crate::domain::ToolCallId;
2517    use std::time::Duration;
2518
2519    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2520        EffectRunner::pair(PathBuf::from("/tmp"))
2521    }
2522
2523    #[test]
2524    fn new_child_suppresses_terminal_title() {
2525        // A subagent's child runner must not emit OSC 2 terminal titles —
2526        // otherwise they leak into a headless parent's stdout and corrupt
2527        // `--format json`/`text` output (caught during live headless testing).
2528        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2529        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2530        let tools = Arc::new(ToolRegistry::new());
2531        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2532        assert!(
2533            !child.terminal_title_enabled,
2534            "subagent child runner must suppress terminal-title escapes"
2535        );
2536    }
2537
2538    #[test]
2539    fn parse_prune_plan_extracts_json_amid_prose() {
2540        let plan = parse_prune_plan(
2541            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2542        )
2543        .expect("should parse");
2544        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2545        assert_eq!(plan.reason, "dupes");
2546    }
2547
2548    #[test]
2549    fn parse_prune_plan_handles_empty_and_garbage() {
2550        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2551            .expect("empty plan parses");
2552        assert!(empty.prune.is_empty());
2553        assert!(parse_prune_plan("no json here").is_none());
2554    }
2555
2556    #[test]
2557    fn memory_title_from_text_is_short_and_nonempty() {
2558        assert_eq!(
2559            memory_title_from_text("prefer ripgrep over grep"),
2560            "prefer ripgrep over grep"
2561        );
2562        assert_eq!(memory_title_from_text("   "), "memory");
2563        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2564        assert!(long.split_whitespace().count() <= 8);
2565    }
2566
2567    #[tokio::test]
2568    async fn dispatch_exit_is_noop_on_runner_state() {
2569        let (mut r, _rx) = runner();
2570        r.dispatch(Cmd::Exit);
2571        assert_eq!(r.scope_count(), 0);
2572    }
2573
2574    #[tokio::test]
2575    async fn dispatch_save_emits_session_saved() {
2576        let (mut r, mut rx) = runner();
2577        r.dispatch(Cmd::SaveConversation(
2578            crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2579        ));
2580        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2581            .await
2582            .expect("sender emits")
2583            .expect("channel alive");
2584        assert!(matches!(msg, Msg::SessionSaved));
2585    }
2586
2587    #[tokio::test]
2588    async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2589        let (mut r, mut rx) = runner();
2590        let t0 = std::time::Instant::now();
2591        r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2592        let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2593            .await
2594            .expect("sender emits")
2595            .expect("channel alive");
2596        assert!(matches!(msg, Msg::StatusDismiss));
2597        assert!(t0.elapsed() >= Duration::from_millis(25));
2598    }
2599
2600    #[test]
2601    fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2602        let msg = mcp_startup_msg("empty", true, Vec::new());
2603        assert!(matches!(
2604            msg,
2605            Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2606        ));
2607    }
2608
2609    #[test]
2610    fn mcp_startup_msg_reports_unstarted_server_as_error() {
2611        let msg = mcp_startup_msg("bad", false, Vec::new());
2612        assert!(matches!(
2613            msg,
2614            Msg::McpServerErrored { name, reason }
2615                if name == "bad" && reason.contains("failed to start")
2616        ));
2617    }
2618
2619    #[tokio::test]
2620    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2621        let (mut r, mut rx) = runner();
2622        let turn = TurnId(77);
2623        {
2624            let scope = r.scope_mut(turn);
2625            scope.spawn(async {
2626                std::future::pending::<()>().await;
2627            });
2628        }
2629        assert_eq!(r.scope_count(), 1);
2630
2631        let start = std::time::Instant::now();
2632        r.dispatch(Cmd::CancelScope(turn));
2633        assert_eq!(r.scope_count(), 0);
2634        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2635            .await
2636            .expect("bounded cancel should emit terminal message")
2637            .expect("channel alive");
2638        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2639        assert!(
2640            start.elapsed() < Duration::from_millis(500),
2641            "cancel terminal message took {:?}",
2642            start.elapsed()
2643        );
2644    }
2645
2646    #[tokio::test]
2647    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2648        // Regression (Axis 1 #9): if a turn's tasks complete and
2649        // `reap_empty_scopes` removes the now-empty scope before the user's
2650        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
2651        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
2652        // even when the scope is already gone.
2653        let (mut r, mut rx) = runner();
2654        let turn = TurnId(88);
2655        {
2656            let scope = r.scope_mut(turn);
2657            scope.spawn(async {}); // completes immediately
2658        }
2659        assert_eq!(r.scope_count(), 1);
2660
2661        // Let the task finish, then any dispatch reaps the now-empty scope.
2662        tokio::time::sleep(Duration::from_millis(20)).await;
2663        r.dispatch(Cmd::Exit);
2664        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2665
2666        // The scope is gone, but the reducer is still `Cancelling`: cancel must
2667        // still produce a terminal message.
2668        r.dispatch(Cmd::CancelScope(turn));
2669        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2670            .await
2671            .expect("cancel on a reaped scope must still emit a terminal message")
2672            .expect("channel alive");
2673        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2674    }
2675
2676    #[tokio::test]
2677    async fn dispatch_call_model_creates_scope() {
2678        let (mut r, _rx) = runner();
2679        let turn = TurnId(7);
2680        let request = crate::domain::ChatRequest {
2681            model_id: "test/m".to_string(),
2682            messages: vec![],
2683            system_prompt: String::new(),
2684            instructions: None,
2685            reasoning: crate::models::ReasoningLevel::Medium,
2686            temperature: 0.7,
2687            max_tokens: 4096,
2688            tools: vec![],
2689
2690            ollama_num_ctx: None,
2691            ollama_allow_ram_offload: None,
2692        };
2693        r.dispatch(Cmd::CallModel { turn, request });
2694        assert_eq!(r.scope_count(), 1);
2695    }
2696
2697    /// F12: after a spawned task completes (here via the
2698    /// no-ProviderFactory error path), the next `dispatch` call reaps
2699    /// the empty scope instead of leaving an orphan entry in the map.
2700    #[tokio::test]
2701    async fn empty_scopes_are_reaped_on_next_dispatch() {
2702        let (mut r, mut rx) = runner();
2703        let turn = TurnId(42);
2704        let request = crate::domain::ChatRequest {
2705            model_id: "test/m".to_string(),
2706            messages: vec![],
2707            system_prompt: String::new(),
2708            instructions: None,
2709            reasoning: crate::models::ReasoningLevel::Medium,
2710            temperature: 0.7,
2711            max_tokens: 4096,
2712            tools: vec![],
2713
2714            ollama_num_ctx: None,
2715            ollama_allow_ram_offload: None,
2716        };
2717        r.dispatch(Cmd::CallModel { turn, request });
2718        assert_eq!(r.scope_count(), 1);
2719
2720        // Runner has no provider bindings → dispatch_call_model hits
2721        // the "not wired" error path and emits UpstreamError, then the
2722        // spawned task returns. Drain that message so we know the task
2723        // ran to completion.
2724        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2725            .await
2726            .expect("upstream error arrived")
2727            .expect("channel alive");
2728        assert!(matches!(msg, Msg::UpstreamError { .. }));
2729
2730        // Give the JoinSet a tick to notice the task finished.
2731        tokio::task::yield_now().await;
2732
2733        // Any subsequent dispatch reaps the now-empty scope.
2734        r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2735        assert_eq!(
2736            r.scope_count(),
2737            0,
2738            "completed scope must be reaped on next dispatch"
2739        );
2740    }
2741
2742    #[tokio::test]
2743    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2744        let (mut r, mut rx) = runner();
2745        let turn = TurnId(7);
2746        let call_id = ToolCallId(1);
2747        let source = crate::models::tool_call::ToolCall {
2748            id: Some("c1".to_string()),
2749            function: crate::models::tool_call::FunctionCall {
2750                name: "read_file".to_string(),
2751                arguments: serde_json::json!({"path": "x"}),
2752            },
2753        };
2754        r.dispatch(Cmd::ExecuteTool {
2755            turn,
2756            call_id,
2757            source,
2758            model_id: "ollama/test".to_string(),
2759            safety_mode: crate::runtime::SafetyMode::Ask,
2760            intent: None,
2761        });
2762        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2763            .await
2764            .expect("some msg")
2765            .expect("channel alive");
2766        assert!(matches!(
2767            first,
2768            Msg::ToolStarted {
2769                turn: t,
2770                call_id: c,
2771            } if t == turn && c == call_id
2772        ));
2773    }
2774
2775    #[tokio::test]
2776    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2777        let (mut r, _rx) = runner();
2778        let turn = TurnId(9);
2779        r.dispatch(Cmd::CallModel {
2780            turn,
2781            request: crate::domain::ChatRequest {
2782                model_id: "m".to_string(),
2783                messages: vec![],
2784                system_prompt: String::new(),
2785                instructions: None,
2786                reasoning: crate::models::ReasoningLevel::Medium,
2787                temperature: 0.7,
2788                max_tokens: 4096,
2789                tools: vec![],
2790
2791                ollama_num_ctx: None,
2792                ollama_allow_ram_offload: None,
2793            },
2794        });
2795        assert_eq!(r.scope_count(), 1);
2796
2797        r.dispatch(Cmd::CancelScope(turn));
2798        assert_eq!(r.scope_count(), 0);
2799    }
2800
2801    #[tokio::test]
2802    async fn shutdown_drains_pending_saves() {
2803        let (mut r, _rx) = runner();
2804        for _ in 0..5 {
2805            r.dispatch(Cmd::SaveConversation(
2806                crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2807            ));
2808        }
2809        // Shutdown waits for all five to complete (should be instant).
2810        let start = std::time::Instant::now();
2811        r.shutdown().await;
2812        assert!(start.elapsed() < Duration::from_secs(2));
2813    }
2814}