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::RefreshInstructions => {
637                let tx = self.msg_tx.clone();
638                let workdir = self.workdir.clone();
639                self.detached.spawn(async move {
640                    let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
641                    let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
642                });
643            },
644            Cmd::RefreshMemory => {
645                let tx = self.msg_tx.clone();
646                let workdir = self.workdir.clone();
647                self.detached.spawn(async move {
648                    let cfg = crate::app::load_config().unwrap_or_default().memory;
649                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
650                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
651                });
652            },
653            Cmd::ListMemory => {
654                let tx = self.msg_tx.clone();
655                let workdir = self.workdir.clone();
656                self.detached.spawn(async move {
657                    let cfg = crate::app::load_config().unwrap_or_default().memory;
658                    let text = match crate::app::memory::load(&workdir, &cfg) {
659                        Some(mem) => mem.index,
660                        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(),
661                    };
662                    let _ = tx.send(Msg::RuntimeText(text)).await;
663                });
664            },
665            Cmd::RememberMemory { text } => {
666                let tx = self.msg_tx.clone();
667                let workdir = self.workdir.clone();
668                self.detached.spawn(async move {
669                    let cfg = crate::app::load_config().unwrap_or_default().memory;
670                    let name = memory_title_from_text(&text);
671                    let (status, kind) = match crate::app::memory::write_memory(
672                        &workdir,
673                        crate::app::memory::MemoryScope::ProjectPrivate,
674                        &name,
675                        &text,
676                        &[],
677                        &text,
678                    ) {
679                        Ok(_) => (
680                            format!("Remembered: {name}"),
681                            crate::domain::StatusKind::Info,
682                        ),
683                        Err(e) => (
684                            format!("Couldn't save memory: {e}"),
685                            crate::domain::StatusKind::Error,
686                        ),
687                    };
688                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
689                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
690                    let _ = tx
691                        .send(Msg::TransientStatus {
692                            text: status,
693                            kind,
694                            dismiss_ms: 3_000,
695                        })
696                        .await;
697                });
698            },
699            Cmd::ForgetMemory { id } => {
700                let tx = self.msg_tx.clone();
701                let workdir = self.workdir.clone();
702                self.detached.spawn(async move {
703                    let cfg = crate::app::load_config().unwrap_or_default().memory;
704                    let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
705                        Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
706                        Ok(None) => (
707                            format!("No memory named '{id}'"),
708                            crate::domain::StatusKind::Info,
709                        ),
710                        Err(e) => (
711                            format!("Couldn't forget memory: {e}"),
712                            crate::domain::StatusKind::Error,
713                        ),
714                    };
715                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
716                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
717                    let _ = tx
718                        .send(Msg::TransientStatus {
719                            text: status,
720                            kind,
721                            dismiss_ms: 3_000,
722                        })
723                        .await;
724                });
725            },
726            Cmd::ConsolidateMemory { model_id } => {
727                let tx = self.msg_tx.clone();
728                let workdir = self.workdir.clone();
729                let providers = self.providers.clone();
730                self.detached.spawn(async move {
731                    consolidate_memory(tx, providers, workdir, model_id).await;
732                });
733            },
734            Cmd::LoadConversation(id) => {
735                let tx = self.msg_tx.clone();
736                let workdir = self.workdir.clone();
737                self.detached.spawn(async move {
738                    match crate::session::ConversationManager::new(&workdir) {
739                        Ok(mgr) => match mgr.load_conversation(&id) {
740                            Ok(history) => {
741                                let _ = tx.send(Msg::ConversationLoaded(history)).await;
742                            },
743                            Err(e) => {
744                                tracing::warn!(id = %id, error = %e, "LoadConversation failed");
745                            },
746                        },
747                        Err(e) => {
748                            tracing::warn!(error = %e, "ConversationManager init failed");
749                        },
750                    }
751                });
752            },
753            Cmd::ListConversations => {
754                let tx = self.msg_tx.clone();
755                let workdir = self.workdir.clone();
756                self.detached.spawn(async move {
757                    let summaries = match crate::session::ConversationManager::new(&workdir) {
758                        Ok(mgr) => mgr
759                            .list_conversations()
760                            .unwrap_or_default()
761                            .into_iter()
762                            .map(|h| crate::domain::ConversationSummary {
763                                id: h.id.clone(),
764                                title: h.title.clone(),
765                                message_count: h.messages.len(),
766                                updated_at: h.updated_at.to_rfc3339(),
767                            })
768                            .collect(),
769                        Err(_) => Vec::new(),
770                    };
771                    let _ = tx.send(Msg::ConversationsListed(summaries)).await;
772                });
773            },
774            Cmd::ListRuntimeTasks { limit } => {
775                let tx = self.msg_tx.clone();
776                // Synchronous rusqlite read — run on the blocking pool so it
777                // never stalls an async worker thread (#40).
778                self.detached.spawn_blocking(move || {
779                    let tasks = crate::runtime::RuntimeClient::auto()
780                        .list_tasks(limit)
781                        .map(|read| read.value)
782                        .unwrap_or_default();
783                    let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
784                });
785            },
786            Cmd::LoadRuntimeTask { id } => {
787                let tx = self.msg_tx.clone();
788                self.detached.spawn_blocking(move || {
789                    let (task, events) = crate::runtime::RuntimeClient::auto()
790                        .task_detail(&id)
791                        .map(|read| (Some(read.value.task), read.value.events))
792                        .unwrap_or((None, Vec::new()));
793                    let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
794                });
795            },
796            Cmd::ListRuntimeProcesses { limit } => {
797                let tx = self.msg_tx.clone();
798                self.detached.spawn_blocking(move || {
799                    let processes = crate::runtime::RuntimeClient::auto()
800                        .list_processes(limit)
801                        .map(|read| read.value)
802                        .unwrap_or_default();
803                    let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
804                });
805            },
806            Cmd::ShowRuntimeProcessLogs { id } => {
807                let tx = self.msg_tx.clone();
808                self.detached.spawn_blocking(move || {
809                    let text = crate::runtime::RuntimeClient::auto()
810                        .process_log(&id, None)
811                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
812                        .unwrap_or_else(|err| format!("Process log error: {}", err));
813                    let _ = tx.blocking_send(Msg::RuntimeText(text));
814                });
815            },
816            Cmd::StopRuntimeProcess { id } => {
817                let tx = self.msg_tx.clone();
818                self.detached.spawn_blocking(move || {
819                    let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
820                        Ok(response) => Msg::TransientStatus {
821                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
822                            kind: crate::domain::StatusKind::Info,
823                            dismiss_ms: 3_000,
824                        },
825                        Err(err) => Msg::TransientStatus {
826                            text: format!("Process stop failed: {}", err),
827                            kind: crate::domain::StatusKind::Warn,
828                            dismiss_ms: 5_000,
829                        },
830                    };
831                    let _ = tx.blocking_send(msg);
832                });
833            },
834            Cmd::RestartRuntimeProcess { id } => {
835                let tx = self.msg_tx.clone();
836                self.detached.spawn_blocking(move || {
837                    let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
838                        Ok(response) => Msg::TransientStatus {
839                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
840                            kind: crate::domain::StatusKind::Info,
841                            dismiss_ms: 3_000,
842                        },
843                        Err(err) => Msg::TransientStatus {
844                            text: format!("Process restart failed: {}", err),
845                            kind: crate::domain::StatusKind::Warn,
846                            dismiss_ms: 5_000,
847                        },
848                    };
849                    let _ = tx.blocking_send(msg);
850                });
851            },
852            Cmd::OpenRuntimeTarget { target } => {
853                self.detached.spawn_blocking(move || {
854                    let resolved = crate::runtime::RuntimeService::open_default()
855                        .and_then(|service| service.resolve_open_target(&target))
856                        .unwrap_or(target);
857                    // #63: the resolved value can be a `detected_url`/`log_path`
858                    // from a `processes` row — validate before the OS opener,
859                    // exactly like `open_process`.
860                    if let Err(err) = crate::runtime::validate_open_target(&resolved) {
861                        tracing::warn!(error = %err, "refusing to open runtime target");
862                        return;
863                    }
864                    crate::utils::open_file(resolved);
865                });
866            },
867            Cmd::ShowRuntimePorts => {
868                let tx = self.msg_tx.clone();
869                self.detached.spawn_blocking(move || {
870                    let text = crate::runtime::RuntimeClient::auto()
871                        .ports()
872                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
873                        .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
874                    let _ = tx.blocking_send(Msg::RuntimeText(text));
875                });
876            },
877            Cmd::ListRuntimeApprovals => {
878                let tx = self.msg_tx.clone();
879                self.detached.spawn_blocking(move || {
880                    let approvals = crate::runtime::RuntimeClient::auto()
881                        .list_approvals()
882                        .map(|read| read.value)
883                        .unwrap_or_default();
884                    let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
885                });
886            },
887            Cmd::DecideRuntimeApproval { id, decision } => {
888                let tx = self.msg_tx.clone();
889                self.detached.spawn_blocking(move || {
890                    let result = if decision == "approved" {
891                        crate::runtime::RuntimeClient::auto().approve(&id)
892                    } else {
893                        crate::runtime::RuntimeClient::auto().deny(&id)
894                    };
895                    let msg = match result {
896                        Ok(result) => Msg::TransientStatus {
897                            text: if result.replayed {
898                                format!("Approval {} {}: {}", id, decision, result.summary)
899                            } else {
900                                format!("Approval {} {}", id, decision)
901                            },
902                            kind: crate::domain::StatusKind::Info,
903                            dismiss_ms: 3_000,
904                        },
905                        Err(err) => Msg::TransientStatus {
906                            text: format!("Approval update failed: {}", err),
907                            kind: crate::domain::StatusKind::Warn,
908                            dismiss_ms: 5_000,
909                        },
910                    };
911                    let _ = tx.blocking_send(msg);
912                });
913            },
914            Cmd::ListRuntimeCheckpoints { limit } => {
915                let tx = self.msg_tx.clone();
916                self.detached.spawn_blocking(move || {
917                    let checkpoints = crate::runtime::RuntimeClient::auto()
918                        .list_checkpoints(limit)
919                        .map(|read| read.value)
920                        .unwrap_or_default();
921                    let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
922                });
923            },
924            Cmd::ListRuntimePlugins => {
925                let tx = self.msg_tx.clone();
926                self.detached.spawn_blocking(move || {
927                    let plugins = crate::runtime::RuntimeClient::auto()
928                        .list_plugins()
929                        .map(|read| read.value)
930                        .unwrap_or_default();
931                    let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
932                });
933            },
934            Cmd::UpdateRuntimeTaskStatus {
935                id,
936                status,
937                final_report,
938            } => {
939                let tx = self.msg_tx.clone();
940                self.detached.spawn_blocking(move || {
941                    let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
942                        store
943                            .tasks()
944                            .update_status(&id, status, final_report.as_deref())
945                    }) {
946                        Ok(()) => Msg::TransientStatus {
947                            text: format!("Task {} -> {}", id, status),
948                            kind: crate::domain::StatusKind::Info,
949                            dismiss_ms: 3_000,
950                        },
951                        Err(err) => Msg::TransientStatus {
952                            text: format!("Task update failed: {}", err),
953                            kind: crate::domain::StatusKind::Warn,
954                            dismiss_ms: 4_000,
955                        },
956                    };
957                    let _ = tx.blocking_send(msg);
958                });
959            },
960            Cmd::CreateRuntimeCheckpoint { paths } => {
961                let tx = self.msg_tx.clone();
962                let workdir = self.workdir.clone();
963                self.detached.spawn_blocking(move || {
964                    let pending_action = Some(serde_json::json!({
965                        "source": "tui",
966                        "command": "checkpoint",
967                    }));
968                    let msg =
969                        match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
970                            Ok(manifest) => Msg::TransientStatus {
971                                text: format!(
972                                    "Checkpoint {} created for {} path(s)",
973                                    manifest.id,
974                                    manifest.files.len()
975                                ),
976                                kind: crate::domain::StatusKind::Info,
977                                dismiss_ms: 4_000,
978                            },
979                            Err(err) => Msg::TransientStatus {
980                                text: format!("Checkpoint failed: {}", err),
981                                kind: crate::domain::StatusKind::Warn,
982                                dismiss_ms: 5_000,
983                            },
984                        };
985                    let _ = tx.blocking_send(msg);
986                });
987            },
988            Cmd::RestoreRuntimeCheckpoint { id } => {
989                let tx = self.msg_tx.clone();
990                self.detached.spawn_blocking(move || {
991                    let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
992                        Ok(result) => Msg::TransientStatus {
993                            text: format!(
994                                "Restored checkpoint {} ({} file(s)){}",
995                                result.checkpoint.id,
996                                result.checkpoint.files.len(),
997                                if result.checkpoint.pending_action.is_some() {
998                                    "; pending action available in checkpoint manifest"
999                                } else {
1000                                    ""
1001                                }
1002                            ),
1003                            kind: crate::domain::StatusKind::Info,
1004                            dismiss_ms: 4_000,
1005                        },
1006                        Err(err) => Msg::TransientStatus {
1007                            text: format!("Restore failed: {}", err),
1008                            kind: crate::domain::StatusKind::Warn,
1009                            dismiss_ms: 5_000,
1010                        },
1011                    };
1012                    let _ = tx.blocking_send(msg);
1013                });
1014            },
1015            Cmd::ShowRuntimeModelInfo { model } => {
1016                let tx = self.msg_tx.clone();
1017                self.detached.spawn_blocking(move || {
1018                    let text = runtime_model_info_text(&model);
1019                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1020                });
1021            },
1022            Cmd::InitMcpServers(configs) => {
1023                let tx = self.msg_tx.clone();
1024                self.detached.spawn(async move {
1025                    if configs.is_empty() {
1026                        return;
1027                    }
1028                    crate::mcp::manager_ref::mark_init_started();
1029                    let manager =
1030                        std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1031                    // Emit a Ready or Errored per server based on
1032                    // what came up. Zero-tool servers are still ready
1033                    // if the manager has an active client for them.
1034                    for (name, _cfg) in configs.iter() {
1035                        let server_tools: Vec<crate::domain::McpToolSpec> = manager
1036                            .get_all_tools()
1037                            .iter()
1038                            .filter(|(server, _)| server == name)
1039                            .map(|(_, def)| crate::domain::McpToolSpec {
1040                                name: def.name.clone(),
1041                                description: def.description.clone(),
1042                                input_schema: def.input_schema.clone(),
1043                            })
1044                            .collect();
1045                        let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1046                        let _ = tx.send(msg).await;
1047                    }
1048                    crate::mcp::manager_ref::set_manager(manager);
1049                    crate::mcp::manager_ref::mark_init_complete();
1050                });
1051            },
1052            Cmd::StopMcpServer { name } => {
1053                let tx = self.msg_tx.clone();
1054                self.detached.spawn(async move {
1055                    // Actually kill the child before claiming it's stopped —
1056                    // otherwise the UI says "stopped" while the server runs on.
1057                    if let Some(mgr) = crate::mcp::manager_ref::get() {
1058                        mgr.stop_server(&name).await;
1059                    }
1060                    let _ = tx.send(Msg::McpServerStopped { name }).await;
1061                });
1062            },
1063            Cmd::PullOllamaModel { model } => {
1064                let tx = self.msg_tx.clone();
1065                self.detached.spawn(async move {
1066                    dispatch_pull_ollama_model(tx, model).await;
1067                });
1068            },
1069            Cmd::OpenInSystem(path) => {
1070                self.detached.spawn(async move {
1071                    let _ = tokio::task::spawn_blocking(move || {
1072                        crate::utils::open_file(&path);
1073                    })
1074                    .await;
1075                });
1076            },
1077            Cmd::DismissStatusAfter { ms } => {
1078                let tx = self.msg_tx.clone();
1079                self.detached.spawn(async move {
1080                    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1081                    let _ = tx.send(Msg::StatusDismiss).await;
1082                });
1083            },
1084            Cmd::WriteImageToTemp {
1085                path,
1086                bytes,
1087                format: _,
1088            } => {
1089                self.detached.spawn(async move {
1090                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1091                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1092                    }
1093                });
1094            },
1095            Cmd::ReadClipboard => {
1096                let tx = self.msg_tx.clone();
1097                self.detached.spawn(async move {
1098                    dispatch_read_clipboard(tx).await;
1099                });
1100            },
1101            Cmd::CopyToClipboard(text) => {
1102                let tx = self.msg_tx.clone();
1103                self.detached.spawn(async move {
1104                    dispatch_copy_to_clipboard(text, tx).await;
1105                });
1106            },
1107            Cmd::Exit => {
1108                // The main loop observes `state.should_exit` after
1109                // the reducer returns; the runner doesn't need to
1110                // take any special action. Documented here for
1111                // exhaustiveness.
1112            },
1113            Cmd::SetTerminalTitle(title) => {
1114                if !self.terminal_title_enabled {
1115                    return;
1116                }
1117                // Offload the terminal write to the blocking pool: writing to
1118                // stdout can block when the terminal (or a downstream pipe) is
1119                // slow, and an async worker must not block on it (#44). The
1120                // OSC-2 title sequence is out-of-band relative to the renderer's
1121                // frame draws, so it doesn't corrupt them.
1122                self.detached.spawn_blocking(move || {
1123                    use std::io::Write;
1124                    let seq = format!("\x1b]2;{}\x07", title);
1125                    let mut stdout = std::io::stdout();
1126                    let _ = stdout.write_all(seq.as_bytes());
1127                    let _ = stdout.flush();
1128                });
1129            },
1130        }
1131    }
1132
1133    /// Async shutdown: cancel every scope, then wait for all spawned
1134    /// work to drain. Bounded by 5 seconds — a hung task past that
1135    /// gets aborted outright by `JoinSet::drop`.
1136    pub async fn shutdown(mut self) {
1137        for (id, scope) in self.scopes.iter() {
1138            tracing::debug!(turn = %id, "shutdown: cancelling scope");
1139            scope.cancel();
1140        }
1141
1142        // The config watcher (#45) is a perpetual loop in `detached`; abort it
1143        // so the drain below doesn't block on it until the bounded timeout.
1144        if let Some(handle) = self.config_watch.take() {
1145            handle.abort();
1146        }
1147
1148        // Drain with a bounded timeout.
1149        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1150
1151        let drain = async {
1152            // If an MCP init is still in flight, its child processes are already
1153            // spawned but `set_manager` hasn't run yet — `get()` below would
1154            // return `None` and we'd leak those children. Wait (bounded) for init
1155            // to settle so the manager is installed before we reap it (#59).
1156            let _ = tokio::time::timeout(
1157                std::time::Duration::from_secs(2),
1158                crate::mcp::manager_ref::wait_ready(),
1159            )
1160            .await;
1161            // Gracefully shut down MCP server children (the stdin-EOF →
1162            // terminate → kill ladder in `McpServerManager::shutdown`). The
1163            // manager lives in a `'static OnceLock` that never drops, so this
1164            // explicit call on the exit path is the only thing that reaps those
1165            // child processes. No-op when no servers were configured.
1166            if let Some(mgr) = crate::mcp::manager_ref::get() {
1167                mgr.shutdown().await;
1168            }
1169            for (_, mut scope) in self.scopes.drain() {
1170                scope.drain().await;
1171            }
1172            while let Some(result) = self.detached.join_next().await {
1173                if let Err(e) = result
1174                    && !e.is_cancelled()
1175                {
1176                    tracing::warn!(error = %e, "shutdown: detached task panic");
1177                }
1178            }
1179        };
1180
1181        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1182    }
1183
1184    /// Test helper: clone the Msg sender so a test can synthesize a
1185    /// message as if it came from an effect handler.
1186    #[doc(hidden)]
1187    pub fn msg_sender(&self) -> MsgSender {
1188        self.msg_tx.clone()
1189    }
1190}
1191
1192/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1193/// cached) and streams its events onto the Msg channel. Without a
1194/// bound `ProviderFactory` (unit tests), emits a single
1195/// `UpstreamError` so the reducer ends the turn cleanly.
1196/// Await a sibling relay task's handle, logging a panic (but not a normal
1197/// post-cancellation abort). These relays run alongside the provider/tool call
1198/// for streaming backpressure; awaiting their handle keeps a stray panic from
1199/// vanishing the way a bare `let _ = handle.await` would (#58, #60).
1200async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1201    if let Err(e) = handle.await
1202        && !e.is_cancelled()
1203    {
1204        tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1205    }
1206}
1207
1208async fn dispatch_call_model(
1209    msg_tx: MsgSender,
1210    providers: Option<Arc<ProviderFactory>>,
1211    turn: TurnId,
1212    mut request: crate::domain::ChatRequest,
1213    token: tokio_util::sync::CancellationToken,
1214) {
1215    use crate::models::UserFacingError;
1216
1217    let Some(factory) = providers else {
1218        let error = UserFacingError {
1219            summary: "not wired".to_string(),
1220            message: "EffectRunner has no ProviderFactory bound".to_string(),
1221            suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1222            category: crate::models::ErrorCategory::Internal,
1223            recoverable: false,
1224        };
1225        let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1226        return;
1227    };
1228
1229    // Lazily resolve the provider for this model.
1230    let provider = match factory.resolve(&request.model_id).await {
1231        Ok(p) => p,
1232        Err(e) => {
1233            let error = classify_error_for_ui(&e);
1234            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1235            return;
1236        },
1237    };
1238    {
1239        // Telemetry write — offload the synchronous DB upserts to the blocking
1240        // pool so they never stall this model-call dispatch path, which runs on
1241        // every turn (#39).
1242        let model_id = request.model_id.clone();
1243        let caps = provider.capabilities().clone();
1244        tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps));
1245    }
1246    if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1247        let _ = msg_tx
1248            .send(Msg::TransientStatus {
1249                text: format!(
1250                    "{} does not advertise tool support; Mermaid will send the turn without tools",
1251                    request.model_id
1252                ),
1253                kind: crate::domain::StatusKind::Warn,
1254                dismiss_ms: 6_000,
1255            })
1256            .await;
1257        request.tools.clear();
1258    }
1259
1260    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1261        crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1262    });
1263    let context_snapshot =
1264        crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1265    let _ = msg_tx
1266        .send(Msg::ContextUsageEstimated {
1267            turn,
1268            snapshot: context_snapshot.clone(),
1269        })
1270        .await;
1271
1272    let policy = CompactionPolicy::default();
1273    let mut compacted_before_stream = false;
1274    if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1275        let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1276        match run_compaction(
1277            Arc::clone(&provider),
1278            turn,
1279            compaction,
1280            context_snapshot.clone(),
1281            max_context_tokens,
1282            token.clone(),
1283        )
1284        .await
1285        {
1286            Ok(result) => {
1287                request.messages = result.replacement_messages.clone();
1288                compacted_before_stream = true;
1289                let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1290            },
1291            Err(err) => {
1292                // Auto-compaction is best-effort. If it can't reduce the
1293                // context — the estimate is roughest exactly at the limit, so
1294                // a large preserved tail can read `after >= before` — don't
1295                // kill the turn. Log it, surface a soft warning, and proceed
1296                // with the original request; the provider's own context limit
1297                // is the real gate. (Manual `/compact` keeps its hard error
1298                // via `run_compaction`'s reduction guard.)
1299                if token.is_cancelled() {
1300                    return;
1301                }
1302                tracing::warn!(
1303                    turn = %turn,
1304                    error = %err,
1305                    "auto-compaction failed; proceeding with the un-compacted request",
1306                );
1307                let _ = msg_tx
1308                    .send(Msg::CompactionFailed {
1309                        turn,
1310                        trigger: CompactionTrigger::AutoThreshold,
1311                        message: err.to_string(),
1312                        kind: crate::domain::StatusKind::Warn,
1313                    })
1314                    .await;
1315            },
1316        }
1317    }
1318
1319    // Build a StreamContext — provider writes typed events into the
1320    // internal sink; we relay each to the reducer as a Msg.
1321    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1322    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1323
1324    // Drain stream events into Msgs on a sibling task. Ends when the sink
1325    // closes (provider's final `Done` or completion) OR the turn token is
1326    // cancelled — `select!`ing on the token ties this relay to the turn's
1327    // structured cancellation so a cancel drops it within a tick instead of
1328    // waiting on the next event. (A separate task is required: the relay must
1329    // run concurrently with `provider.chat` for streaming backpressure.)
1330    let relay_tx = msg_tx.clone();
1331    let relay_token = token.clone();
1332    let relay = tokio::spawn(async move {
1333        loop {
1334            let event = tokio::select! {
1335                biased;
1336                _ = relay_token.cancelled() => break,
1337                ev = stream_rx.recv() => match ev {
1338                    Some(ev) => ev,
1339                    None => break,
1340                },
1341            };
1342            let msg = match event {
1343                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1344                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1345                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1346                StreamEvent::ThinkingSignature(_) => continue, // folded into Done below
1347                StreamEvent::Done {
1348                    usage,
1349                    thinking_signature,
1350                    stop_reason,
1351                } => Msg::StreamDone {
1352                    turn,
1353                    usage,
1354                    thinking_signature,
1355                    stop_reason,
1356                },
1357            };
1358            if relay_tx.send(msg).await.is_err() {
1359                break;
1360            }
1361        }
1362    });
1363
1364    // Run the actual provider. On error, the relay will have
1365    // already emitted partial events; we follow with a single
1366    // UpstreamError to terminate the turn cleanly.
1367    //
1368    // `ModelError::Cancelled` is swallowed — the terminal
1369    // `Msg::TurnCancelled` is emitted from `drop_scope` after the
1370    // turn's `TurnScope` drains. Emitting `UpstreamError` here would
1371    // commit a "cancelled" message the user didn't ask to see.
1372    match provider.chat(request.clone(), ctx).await {
1373        Ok(_final_response) => {
1374            // Success — the final `Done` flowed through the sink.
1375        },
1376        Err(crate::models::ModelError::Cancelled) => {
1377            // Silent: `drop_scope` will emit `Msg::TurnCancelled`.
1378        },
1379        Err(e) => {
1380            let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1381            if retry_context_limit {
1382                let latest_snapshot =
1383                    crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1384                let compaction =
1385                    CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1386                match run_compaction(
1387                    Arc::clone(&provider),
1388                    turn,
1389                    compaction,
1390                    latest_snapshot,
1391                    max_context_tokens,
1392                    token.clone(),
1393                )
1394                .await
1395                {
1396                    Ok(result) => {
1397                        let mut retry_request = request;
1398                        retry_request.messages = result.replacement_messages.clone();
1399                        let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1400                        join_logged(relay, "stream_relay").await;
1401                        dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1402                            .await;
1403                        return;
1404                    },
1405                    Err(compact_err) => {
1406                        let _ = msg_tx
1407                            .send(Msg::CompactionFailed {
1408                                turn,
1409                                trigger: CompactionTrigger::ContextLimitRetry,
1410                                message: compact_err.to_string(),
1411                                kind: crate::domain::StatusKind::Error,
1412                            })
1413                            .await;
1414                    },
1415                }
1416            }
1417            let error = classify_error_for_ui(&e);
1418            run_provider_error_hook(&request.model_id, &error).await;
1419            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1420        },
1421    }
1422
1423    join_logged(relay, "stream_relay").await;
1424}
1425
1426async fn dispatch_provider_stream(
1427    msg_tx: MsgSender,
1428    provider: Arc<dyn ModelProvider>,
1429    turn: TurnId,
1430    request: crate::domain::ChatRequest,
1431    token: tokio_util::sync::CancellationToken,
1432) {
1433    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1434    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1435    let relay_tx = msg_tx.clone();
1436    let relay_token = token.clone();
1437    let relay = tokio::spawn(async move {
1438        loop {
1439            let event = tokio::select! {
1440                biased;
1441                _ = relay_token.cancelled() => break,
1442                ev = stream_rx.recv() => match ev {
1443                    Some(ev) => ev,
1444                    None => break,
1445                },
1446            };
1447            let msg = match event {
1448                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1449                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1450                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1451                StreamEvent::ThinkingSignature(_) => continue,
1452                StreamEvent::Done {
1453                    usage,
1454                    thinking_signature,
1455                    stop_reason,
1456                } => Msg::StreamDone {
1457                    turn,
1458                    usage,
1459                    thinking_signature,
1460                    stop_reason,
1461                },
1462            };
1463            if relay_tx.send(msg).await.is_err() {
1464                break;
1465            }
1466        }
1467    });
1468
1469    let model_id = request.model_id.clone();
1470    match provider.chat(request, ctx).await {
1471        Ok(_) | Err(ModelError::Cancelled) => {},
1472        Err(e) => {
1473            let error = classify_error_for_ui(&e);
1474            run_provider_error_hook(&model_id, &error).await;
1475            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1476        },
1477    }
1478
1479    join_logged(relay, "stream_relay").await;
1480}
1481
1482/// Run plugin hooks OFF the async executor. `run_plugin_hooks` is synchronous —
1483/// it spawns hook children and bounded-waits on them — so calling it inline
1484/// would block a tokio worker, or (on the `dispatch` path) the whole event loop.
1485/// `spawn_blocking` moves it to the blocking pool. Hooks are fire-and-forget
1486/// observers, so the result is dropped.
1487async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1488    let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1489        .await;
1490}
1491
1492async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1493    fire_plugin_hooks(
1494        "provider_error",
1495        serde_json::json!({
1496            "model_id": model_id,
1497            "summary": &error.summary,
1498            "message": &error.message,
1499            "category": format!("{:?}", error.category),
1500            "recoverable": error.recoverable,
1501        }),
1502    )
1503    .await;
1504}
1505
1506/// Derive a short title for a `/remember` memory from free-text input: the
1507/// first non-empty line, capped to ~8 words / 60 chars. `write_memory`
1508/// slugifies it into the filename.
1509fn memory_title_from_text(text: &str) -> String {
1510    let first = text
1511        .lines()
1512        .find(|l| !l.trim().is_empty())
1513        .unwrap_or("memory")
1514        .trim();
1515    let title: String = first
1516        .split_whitespace()
1517        .take(8)
1518        .collect::<Vec<_>>()
1519        .join(" ")
1520        .chars()
1521        .take(60)
1522        .collect();
1523    if title.trim().is_empty() {
1524        "memory".to_string()
1525    } else {
1526        title
1527    }
1528}
1529
1530const 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.";
1531
1532#[derive(Debug)]
1533struct PrunePlan {
1534    prune: Vec<String>,
1535    reason: String,
1536}
1537
1538/// Extract a `{prune:[...], reason:""}` plan from a model response, tolerating
1539/// prose or code fences around the JSON object.
1540fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1541    let start = text.find('{')?;
1542    let end = text.rfind('}')?;
1543    if end < start {
1544        return None;
1545    }
1546    let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1547    let prune = json
1548        .get("prune")?
1549        .as_array()?
1550        .iter()
1551        .filter_map(|v| v.as_str().map(str::to_string))
1552        .collect();
1553    let reason = json
1554        .get("reason")
1555        .and_then(|v| v.as_str())
1556        .unwrap_or("")
1557        .to_string();
1558    Some(PrunePlan { prune, reason })
1559}
1560
1561/// `/consolidate-memory`: a one-shot model pass that names duplicate/obsolete
1562/// facts to prune (never rewrites — that's the anti-drift rule). The pruned
1563/// files are snapshotted into a checkpoint first, so the prune is reversible.
1564async fn consolidate_memory(
1565    tx: MsgSender,
1566    providers: Option<Arc<ProviderFactory>>,
1567    workdir: std::path::PathBuf,
1568    model_id: String,
1569) {
1570    let items = crate::app::memory::entries_with_bodies(&workdir);
1571    if items.len() < 2 {
1572        let _ = tx
1573            .send(Msg::RuntimeText(format!(
1574                "Nothing to consolidate — {} memor{} saved.",
1575                items.len(),
1576                if items.len() == 1 { "y" } else { "ies" }
1577            )))
1578            .await;
1579        return;
1580    }
1581    let Some(factory) = providers else {
1582        let _ = tx
1583            .send(Msg::RuntimeText(
1584                "Memory consolidation needs a model provider, which isn't bound in this session."
1585                    .to_string(),
1586            ))
1587            .await;
1588        return;
1589    };
1590
1591    let mut listing = String::new();
1592    for (entry, body) in &items {
1593        let id = entry
1594            .path
1595            .file_stem()
1596            .and_then(|s| s.to_str())
1597            .unwrap_or(entry.name.as_str());
1598        listing.push_str(&format!(
1599            "- id: {id}\n  scope: {}\n  description: {}\n  body: {}\n",
1600            entry.scope.as_str(),
1601            entry.description,
1602            body.replace('\n', " ").trim(),
1603        ));
1604    }
1605    let user = format!(
1606        "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1607        items.len(),
1608        listing
1609    );
1610    let request = crate::domain::ChatRequest {
1611        model_id: model_id.clone(),
1612        messages: vec![crate::models::ChatMessage::user(user)],
1613        system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1614        instructions: None,
1615        reasoning: crate::models::ReasoningLevel::None,
1616        temperature: 0.0,
1617        max_tokens: 1024,
1618        tools: Vec::new(),
1619    };
1620
1621    let provider = match factory.resolve(&model_id).await {
1622        Ok(p) => p,
1623        Err(e) => {
1624            let _ = tx
1625                .send(Msg::RuntimeText(format!(
1626                    "Memory consolidation failed: {e}"
1627                )))
1628                .await;
1629            return;
1630        },
1631    };
1632    let token = tokio_util::sync::CancellationToken::new();
1633    let text =
1634        match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1635            Ok((t, _)) => t,
1636            Err(e) => {
1637                let _ = tx
1638                    .send(Msg::RuntimeText(format!(
1639                        "Memory consolidation failed: {e}"
1640                    )))
1641                    .await;
1642                return;
1643            },
1644        };
1645
1646    let Some(plan) = parse_prune_plan(&text) else {
1647        let _ = tx
1648            .send(Msg::RuntimeText(
1649                "Memory consolidation: couldn't parse the model's plan; nothing changed."
1650                    .to_string(),
1651            ))
1652            .await;
1653        return;
1654    };
1655    if plan.prune.is_empty() {
1656        let reason = if plan.reason.is_empty() {
1657            String::new()
1658        } else {
1659            format!(" {}", plan.reason)
1660        };
1661        let _ = tx
1662            .send(Msg::RuntimeText(format!(
1663                "Memory consolidation: nothing to prune.{reason}"
1664            )))
1665            .await;
1666        return;
1667    }
1668
1669    // Snapshot the to-be-pruned files first so the prune is reversible.
1670    let paths: Vec<std::path::PathBuf> = plan
1671        .prune
1672        .iter()
1673        .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1674        .collect();
1675    if !paths.is_empty() {
1676        let _ = crate::runtime::create_checkpoint(
1677            &workdir,
1678            &paths,
1679            Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1680        );
1681    }
1682
1683    let mut pruned = Vec::new();
1684    for id in &plan.prune {
1685        if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1686            pruned.push(id.clone());
1687        }
1688    }
1689
1690    let cfg = crate::app::load_config().unwrap_or_default().memory;
1691    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1692    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1693
1694    let report = if pruned.is_empty() {
1695        "Memory consolidation: the model named facts to prune, but none matched existing memories."
1696            .to_string()
1697    } else {
1698        format!(
1699            "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1700            pruned.len(),
1701            if pruned.len() == 1 { "" } else { "s" },
1702            pruned.join(", "),
1703            if plan.reason.is_empty() {
1704                String::new()
1705            } else {
1706                format!(" Reason: {}.", plan.reason)
1707            },
1708        )
1709    };
1710    let _ = tx.send(Msg::RuntimeText(report)).await;
1711}
1712
1713async fn dispatch_compact_conversation(
1714    msg_tx: MsgSender,
1715    providers: Option<Arc<ProviderFactory>>,
1716    turn: TurnId,
1717    request: CompactionRequest,
1718    token: tokio_util::sync::CancellationToken,
1719) {
1720    let Some(factory) = providers else {
1721        let _ = msg_tx
1722            .send(Msg::CompactionFailed {
1723                turn,
1724                trigger: request.trigger,
1725                message: "EffectRunner has no ProviderFactory bound".to_string(),
1726                kind: crate::domain::StatusKind::Error,
1727            })
1728            .await;
1729        return;
1730    };
1731
1732    let provider = match factory.resolve(&request.chat.model_id).await {
1733        Ok(provider) => provider,
1734        Err(err) => {
1735            let _ = msg_tx
1736                .send(Msg::CompactionFailed {
1737                    turn,
1738                    trigger: request.trigger,
1739                    message: err.to_string(),
1740                    kind: crate::domain::StatusKind::Error,
1741                })
1742                .await;
1743            return;
1744        },
1745    };
1746
1747    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1748        crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1749    });
1750    let before_snapshot =
1751        crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1752
1753    let trigger = request.trigger;
1754    match run_compaction(
1755        provider,
1756        turn,
1757        request,
1758        before_snapshot,
1759        max_context_tokens,
1760        token,
1761    )
1762    .await
1763    {
1764        Ok(result) => {
1765            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1766        },
1767        Err(err) => {
1768            let _ = msg_tx
1769                .send(Msg::CompactionFailed {
1770                    turn,
1771                    trigger,
1772                    message: err.to_string(),
1773                    kind: crate::domain::StatusKind::Error,
1774                })
1775                .await;
1776        },
1777    }
1778}
1779
1780async fn run_compaction(
1781    provider: Arc<dyn ModelProvider>,
1782    turn: TurnId,
1783    request: CompactionRequest,
1784    before_snapshot: crate::domain::ContextUsageSnapshot,
1785    max_context_tokens: Option<usize>,
1786    token: tokio_util::sync::CancellationToken,
1787) -> Result<CompactionResult, ModelError> {
1788    let started = Instant::now();
1789    let prepared = crate::domain::prepare_compaction(&request, max_context_tokens)
1790        .map_err(|skip| ModelError::InvalidRequest(skip.to_string()))?;
1791
1792    let summary_request = crate::domain::build_summary_request(
1793        &request.chat,
1794        &prepared,
1795        request.instructions.as_deref(),
1796        request.policy,
1797    );
1798    let (draft, draft_usage) =
1799        collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1800            .await?;
1801    let draft_summary = crate::domain::normalize_summary(&draft);
1802    if draft_summary.trim().is_empty() {
1803        return Err(ModelError::InvalidRequest(
1804            "compaction produced an empty summary".to_string(),
1805        ));
1806    }
1807
1808    let verify_request = crate::domain::build_verification_request(
1809        &request.chat,
1810        &prepared,
1811        &draft_summary,
1812        request.instructions.as_deref(),
1813        request.policy,
1814    );
1815    let (final_summary, verify_usage, verified, verification_error) =
1816        match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1817            Ok((verified_text, verify_usage)) => {
1818                let verified_summary = crate::domain::normalize_summary(&verified_text);
1819                if verified_summary.trim().is_empty() {
1820                    (
1821                        draft_summary,
1822                        verify_usage,
1823                        false,
1824                        Some("verification returned an empty summary".to_string()),
1825                    )
1826                } else {
1827                    (verified_summary, verify_usage, true, None)
1828                }
1829            },
1830            Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1831            Err(err) => (draft_summary, None, false, Some(err.to_string())),
1832        };
1833
1834    let id = format!(
1835        "compact_{}",
1836        chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1837    );
1838    let mut record = crate::domain::CompactionRecord {
1839        id,
1840        trigger: request.trigger,
1841        created_at: chrono::Local::now(),
1842        before_tokens: before_snapshot.used_tokens,
1843        after_tokens: 0,
1844        archived_message_count: prepared.archived_messages.len(),
1845        preserved_message_count: prepared.preserved_messages.len(),
1846        summary_tokens: final_summary.len().div_ceil(4),
1847        duration_secs: started.elapsed().as_secs_f64(),
1848        verified,
1849        verification_error,
1850        focus: request.instructions.clone(),
1851        archive_path: None,
1852    };
1853
1854    let mut replacement =
1855        crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1856    let mut compacted_request = request.chat.clone();
1857    compacted_request.messages = replacement.clone();
1858    let mut after_snapshot =
1859        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1860    record.after_tokens = after_snapshot.used_tokens;
1861    record.duration_secs = started.elapsed().as_secs_f64();
1862    replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1863    compacted_request.messages = replacement.clone();
1864    after_snapshot =
1865        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1866    record.after_tokens = after_snapshot.used_tokens;
1867
1868    if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1869        return Err(ModelError::InvalidRequest(format!(
1870            "compaction did not reduce context ({} -> {} tokens)",
1871            before_snapshot.used_tokens, after_snapshot.used_tokens
1872        )));
1873    }
1874
1875    if crate::domain::context_exceeds_hard_limit(
1876        &after_snapshot,
1877        &compacted_request,
1878        request.policy,
1879    ) {
1880        return Err(ModelError::InvalidRequest(format!(
1881            "compacted context still exceeds response reserve ({} tokens used)",
1882            after_snapshot.used_tokens
1883        )));
1884    }
1885
1886    Ok(CompactionResult {
1887        record,
1888        replacement_messages: replacement,
1889        archived_messages: prepared.archived_messages,
1890        before_snapshot,
1891        after_snapshot,
1892        usage: crate::domain::combine_usage(draft_usage, verify_usage),
1893    })
1894}
1895
1896async fn collect_compaction_text(
1897    provider: Arc<dyn ModelProvider>,
1898    turn: TurnId,
1899    request: crate::domain::ChatRequest,
1900    token: tokio_util::sync::CancellationToken,
1901) -> Result<(String, Option<TokenUsage>), ModelError> {
1902    // Shared with the Auto-mode safety classifier — see
1903    // `crate::providers::model::collect_text`.
1904    crate::providers::model::collect_text(provider, turn, request, token).await
1905}
1906
1907fn record_provider_capabilities(
1908    model_id: &str,
1909    caps: &crate::providers::capabilities::Capabilities,
1910) {
1911    let (provider, model) = split_model_id(model_id);
1912    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1913        for (key, value) in [
1914            ("tools_support", caps.supports_tools.to_string()),
1915            ("vision_support", caps.supports_vision.to_string()),
1916            (
1917                "context_limit",
1918                caps.max_context_tokens
1919                    .map(|v| v.to_string())
1920                    .unwrap_or_else(|| "unknown".to_string()),
1921            ),
1922            (
1923                "reasoning_parameter_shape",
1924                format!("{:?}", caps.supports_reasoning),
1925            ),
1926            (
1927                "streaming_usage_available",
1928                "provider_dependent".to_string(),
1929            ),
1930            ("token_usage_field_shape", "normalized".to_string()),
1931        ] {
1932            let _ = store
1933                .provider_probes()
1934                .upsert(crate::runtime::NewProviderProbe {
1935                    provider: provider.clone(),
1936                    model_id: model.clone(),
1937                    capability_key: key.to_string(),
1938                    capability_value: value,
1939                    confidence: "verified".to_string(),
1940                    error: None,
1941                });
1942        }
1943    }
1944}
1945
1946fn split_model_id(model_id: &str) -> (String, String) {
1947    match model_id.split_once('/') {
1948        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
1949            (provider.to_ascii_lowercase(), model.to_string())
1950        },
1951        _ => ("ollama".to_string(), model_id.to_string()),
1952    }
1953}
1954
1955fn is_context_limit_error(error: &ModelError) -> bool {
1956    let text = error.to_string().to_lowercase();
1957    text.contains("context")
1958        && (text.contains("too large")
1959            || text.contains("exceed")
1960            || text.contains("maximum")
1961            || text.contains("token"))
1962}
1963
1964/// Dispatch an `ExecuteTool` command.
1965#[allow(clippy::too_many_arguments)]
1966async fn dispatch_execute_tool(
1967    msg_tx: MsgSender,
1968    tools: Option<Arc<ToolRegistry>>,
1969    workdir: PathBuf,
1970    turn: TurnId,
1971    call_id: crate::domain::ToolCallId,
1972    source: crate::models::tool_call::ToolCall,
1973    token: tokio_util::sync::CancellationToken,
1974    background: tokio_util::sync::CancellationToken,
1975    config: Arc<crate::app::Config>,
1976    model_id: String,
1977    task_id: Option<String>,
1978    safety_mode: crate::runtime::SafetyMode,
1979    intent: Option<String>,
1980    classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
1981    approval: Option<crate::providers::ApprovalBroker>,
1982) {
1983    let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
1984
1985    let Some(registry) = tools else {
1986        let _ = msg_tx
1987            .send(Msg::ToolFinished {
1988                turn,
1989                call_id,
1990                outcome: crate::domain::ToolOutcome::error(
1991                    "EffectRunner has no ToolRegistry bound",
1992                    0.0,
1993                ),
1994            })
1995            .await;
1996        return;
1997    };
1998
1999    // Route MCP-prefixed calls to the mcp proxy, which takes
2000    // {server_name, tool_name, arguments}. The raw model call has
2001    // those embedded in the function name and arguments respectively.
2002    let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2003        let rest = &source.function.name[5..];
2004        if let Some((server, tool)) = rest.split_once("__") {
2005            (
2006                "mcp_proxy",
2007                serde_json::json!({
2008                    "server_name": server,
2009                    "tool_name": tool,
2010                    "arguments": source.function.arguments.clone(),
2011                }),
2012            )
2013        } else {
2014            let _ = msg_tx
2015                .send(Msg::ToolFinished {
2016                    turn,
2017                    call_id,
2018                    outcome: crate::domain::ToolOutcome::error(
2019                        format!("invalid MCP tool name: {}", source.function.name),
2020                        0.0,
2021                    ),
2022                })
2023                .await;
2024            return;
2025        }
2026    } else {
2027        (
2028            source.function.name.as_str(),
2029            source.function.arguments.clone(),
2030        )
2031    };
2032    let tool_run_id =
2033        start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2034
2035    let Some(tool) = registry.get(tool_key) else {
2036        let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2037        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2038        let _ = msg_tx
2039            .send(Msg::ToolFinished {
2040                turn,
2041                call_id,
2042                outcome,
2043            })
2044            .await;
2045        return;
2046    };
2047
2048    // Bridge the tool's progress channel to `Msg::ToolProgress`.
2049    // A sibling task drains progress events while the tool runs.
2050    // The channel closes when `progress_tx` drops (when `ctx`
2051    // drops at the end of `tool.execute`), which terminates the
2052    // relay loop cleanly.
2053    let (progress_tx, mut progress_rx) = mpsc::channel(16);
2054    let relay_tx = msg_tx.clone();
2055    let relay_token = token.clone();
2056    let progress_relay = tokio::spawn(async move {
2057        loop {
2058            let event = tokio::select! {
2059                biased;
2060                _ = relay_token.cancelled() => break,
2061                ev = progress_rx.recv() => match ev {
2062                    Some(ev) => ev,
2063                    None => break,
2064                },
2065            };
2066            if relay_tx
2067                .send(Msg::ToolProgress {
2068                    turn,
2069                    call_id,
2070                    event,
2071                })
2072                .await
2073                .is_err()
2074            {
2075                break;
2076            }
2077        }
2078    });
2079
2080    let mut ctx = ExecContext::new(
2081        token,
2082        progress_tx,
2083        call_id,
2084        turn,
2085        workdir,
2086        config,
2087        model_id,
2088        task_id,
2089        safety_mode,
2090        intent,
2091        classifier,
2092        approval,
2093    );
2094    ctx.background = background;
2095    let before_payload = serde_json::json!({
2096        "turn_id": turn.0,
2097        "call_id": call_id.0,
2098        "tool": tool_key,
2099    });
2100    fire_plugin_hooks("before_tool_use", before_payload).await;
2101    let outcome = tool.execute(args, ctx).await;
2102    let after_payload = serde_json::json!({
2103        "turn_id": turn.0,
2104        "call_id": call_id.0,
2105        "tool": tool_key,
2106        "status": tool_status_label(outcome.status),
2107        "summary": &outcome.summary,
2108    });
2109    fire_plugin_hooks("after_tool_use", after_payload).await;
2110    finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2111    join_logged(progress_relay, "tool_progress_relay").await;
2112    let _ = msg_tx
2113        .send(Msg::ToolFinished {
2114            turn,
2115            call_id,
2116            outcome,
2117        })
2118        .await;
2119}
2120
2121async fn start_runtime_tool_run(
2122    task_id: Option<&str>,
2123    turn: TurnId,
2124    call_id: crate::domain::ToolCallId,
2125    tool_name: &str,
2126    args: &serde_json::Value,
2127) -> Option<String> {
2128    // Synchronous rusqlite write on the hot tool-execution path — offload it to
2129    // the blocking pool. The id is needed by `finish`, so we await the result
2130    // (unlike `finish`, which is fire-and-forget) (#39).
2131    let task_id = task_id.map(str::to_string);
2132    let tool_name = tool_name.to_string();
2133    let args_json = serde_json::to_string(args).ok();
2134    tokio::task::spawn_blocking(move || {
2135        crate::runtime::RuntimeStore::open_default()
2136            .and_then(|store| {
2137                store.tool_runs().start(crate::runtime::NewToolRun {
2138                    id: None,
2139                    task_id,
2140                    turn_id: Some(turn.0.to_string()),
2141                    call_id: Some(call_id.0.to_string()),
2142                    tool_name,
2143                    args_json,
2144                })
2145            })
2146            .map(|record| record.id)
2147            .ok()
2148    })
2149    .await
2150    .ok()
2151    .flatten()
2152}
2153
2154fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2155    let Some(tool_run_id) = tool_run_id else {
2156        return;
2157    };
2158    let tool_run_id = tool_run_id.to_string();
2159    let status = tool_status_label(outcome.status).to_string();
2160    let output_json = serde_json::to_string(&serde_json::json!({
2161        "status": tool_status_label(outcome.status),
2162        "summary": &outcome.summary,
2163        "model_content": &outcome.model_content,
2164        "error": &outcome.error,
2165        "metadata": &outcome.metadata,
2166        "artifacts": &outcome.artifacts,
2167        "duration_secs": outcome.duration_secs,
2168    }))
2169    .ok();
2170    // Fire-and-forget telemetry write on the blocking pool — don't stall the
2171    // tool-finish path waiting on rusqlite (#39).
2172    tokio::task::spawn_blocking(move || {
2173        if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2174            let _ = store
2175                .tool_runs()
2176                .finish(&tool_run_id, &status, output_json.as_deref());
2177        }
2178    });
2179}
2180
2181fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2182    match status {
2183        crate::domain::ToolStatus::Success => "success",
2184        crate::domain::ToolStatus::Error => "error",
2185        crate::domain::ToolStatus::Cancelled => "cancelled",
2186    }
2187}
2188
2189fn runtime_model_info_text(model: &str) -> String {
2190    let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2191    let mut lines = vec![
2192        format!("Model info: {}", model),
2193        format!("- provider: {}", snapshot.provider),
2194        format!("- model: {}", snapshot.model),
2195        format!("- supports tools: {}", snapshot.supports_tools),
2196        format!("- supports vision: {}", snapshot.supports_vision),
2197        format!("- reasoning: {}", snapshot.reasoning),
2198        format!(
2199            "- context limit: {}",
2200            snapshot
2201                .max_context_tokens
2202                .map(|value: usize| value.to_string())
2203                .unwrap_or_else(|| "unknown".to_string())
2204        ),
2205    ];
2206    if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2207        && let Ok(probes) = store
2208            .provider_probes()
2209            .list(Some(&snapshot.provider), Some(&snapshot.model))
2210        && !probes.is_empty()
2211    {
2212        lines.push(String::new());
2213        lines.push("Cached provider reality records:".to_string());
2214        for probe in probes {
2215            lines.push(format!(
2216                "- {} = {} ({})",
2217                probe.capability_key, probe.capability_value, probe.confidence
2218            ));
2219        }
2220    }
2221    lines.join("\n")
2222}
2223
2224/// Spawn `ollama pull <model>` and stream its stdout lines as
2225/// `Msg::ModelPullProgress` status updates. Emits a final
2226/// `Msg::ModelPullFinished` on successful exit; on failure, emits a
2227/// single `ModelPullProgress` with the error text.
2228async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2229    use tokio::io::{AsyncBufReadExt, BufReader};
2230    use tokio::process::Command;
2231
2232    let mut cmd = Command::new("ollama");
2233    cmd.arg("pull")
2234        .arg(&model)
2235        .stdin(std::process::Stdio::null())
2236        .stdout(std::process::Stdio::piped())
2237        .stderr(std::process::Stdio::piped())
2238        .kill_on_drop(true);
2239
2240    let mut child = match cmd.spawn() {
2241        Ok(c) => c,
2242        Err(e) => {
2243            let _ = tx
2244                .send(Msg::ModelPullProgress(format!(
2245                    "ollama pull failed to start: {}",
2246                    e
2247                )))
2248                .await;
2249            return;
2250        },
2251    };
2252
2253    // Capture the reader's handle instead of orphaning it: the child's stdout
2254    // closes when it exits, so this task finishes right after `child.wait`
2255    // below — we join it there so a panic is logged, not silently lost (#60).
2256    let reader_handle = child.stdout.take().map(|stdout| {
2257        let tx_inner = tx.clone();
2258        tokio::spawn(async move {
2259            let mut reader = BufReader::new(stdout).lines();
2260            while let Ok(Some(line)) = reader.next_line().await {
2261                let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2262            }
2263        })
2264    });
2265
2266    match child.wait().await {
2267        Ok(status) if status.success() => {
2268            let _ = tx.send(Msg::ModelPullFinished { model }).await;
2269        },
2270        Ok(status) => {
2271            let _ = tx
2272                .send(Msg::ModelPullProgress(format!(
2273                    "ollama pull exited with status {}",
2274                    status.code().unwrap_or(-1)
2275                )))
2276                .await;
2277        },
2278        Err(e) => {
2279            let _ = tx
2280                .send(Msg::ModelPullProgress(format!(
2281                    "ollama pull wait error: {}",
2282                    e
2283                )))
2284                .await;
2285        },
2286    }
2287
2288    // The child has exited; its stdout is closed, so the reader is finishing.
2289    // Join it (logging a panic) so it isn't left orphaned (#60).
2290    if let Some(handle) = reader_handle {
2291        join_logged(handle, "ollama_pull_reader").await;
2292    }
2293}
2294
2295fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2296    if started {
2297        Msg::McpServerReady {
2298            name: name.to_string(),
2299            tools,
2300        }
2301    } else {
2302        Msg::McpServerErrored {
2303            name: name.to_string(),
2304            reason: "server failed to start or initialize".to_string(),
2305        }
2306    }
2307}
2308
2309/// Read the system clipboard on a blocking thread and emit a `Msg`
2310/// back into the main loop. Image content wins when present; falls
2311/// back to text; empty or error surface as `Msg::TransientStatus` so
2312/// the user gets visible feedback (a silent no-op on Ctrl+V would be
2313/// confusing, especially on macOS where `osascript` can take ~300ms).
2314///
2315/// `tokio::task::spawn_blocking` is the right primitive: `clipboard::
2316/// has_image` / `read_image_bytes` / `read_text` shell out to xclip /
2317/// wl-paste / pngpaste / PowerShell, all of which block synchronously.
2318async fn dispatch_read_clipboard(tx: MsgSender) {
2319    use crate::domain::{Paste, StatusKind};
2320
2321    enum Outcome {
2322        Image { bytes: Vec<u8>, format: String },
2323        Text(String),
2324        Empty,
2325        Error(String),
2326    }
2327
2328    let outcome = tokio::task::spawn_blocking(|| {
2329        if crate::clipboard::has_image() {
2330            match crate::clipboard::read_image_bytes() {
2331                Ok((bytes, format)) => Outcome::Image { bytes, format },
2332                Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2333            }
2334        } else {
2335            match crate::clipboard::read_text() {
2336                Ok(t) if !t.is_empty() => Outcome::Text(t),
2337                Ok(_) => Outcome::Empty,
2338                Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2339            }
2340        }
2341    })
2342    .await
2343    .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2344
2345    let msg = match outcome {
2346        Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2347        Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2348        Outcome::Empty => Msg::TransientStatus {
2349            text: "Clipboard is empty".to_string(),
2350            kind: StatusKind::Info,
2351            dismiss_ms: 2_000,
2352        },
2353        Outcome::Error(text) => Msg::TransientStatus {
2354            text,
2355            kind: StatusKind::Warn,
2356            dismiss_ms: 4_000,
2357        },
2358    };
2359    let _ = tx.send(msg).await;
2360}
2361
2362/// Write text to the system clipboard on a blocking thread (the platform
2363/// tools shell out and block), then report the result via a transient status.
2364async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2365    use crate::domain::StatusKind;
2366
2367    let char_count = text.chars().count();
2368    let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2369        .await
2370        .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2371
2372    let msg = match result {
2373        Ok(()) => Msg::TransientStatus {
2374            text: format!("Copied {char_count} chars to clipboard"),
2375            kind: StatusKind::Info,
2376            dismiss_ms: 2_000,
2377        },
2378        Err(e) => Msg::TransientStatus {
2379            text: format!("Copy failed: {e}"),
2380            kind: StatusKind::Warn,
2381            dismiss_ms: 4_000,
2382        },
2383    };
2384    let _ = tx.send(msg).await;
2385}
2386
2387fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2388    use crate::models::{ErrorCategory, ModelError, UserFacingError};
2389    match e {
2390        ModelError::Backend(b) => UserFacingError {
2391            summary: "Backend error".to_string(),
2392            message: b.to_string(),
2393            suggestion: "Check the provider endpoint / API key.".to_string(),
2394            category: ErrorCategory::Connection,
2395            recoverable: true,
2396        },
2397        ModelError::Authentication(msg) => UserFacingError {
2398            summary: "Auth error".to_string(),
2399            message: msg.clone(),
2400            suggestion: "Set the env var the provider expects.".to_string(),
2401            category: ErrorCategory::Auth,
2402            recoverable: false,
2403        },
2404        ModelError::RateLimit { retry_after } => UserFacingError {
2405            summary: "Rate limit".to_string(),
2406            message: format!("retry after {:?}", retry_after),
2407            suggestion: "Wait and try again.".to_string(),
2408            category: ErrorCategory::Temporary,
2409            recoverable: true,
2410        },
2411        ModelError::StreamError(msg) => UserFacingError {
2412            summary: "Stream error".to_string(),
2413            message: msg.clone(),
2414            suggestion: "Retry the request.".to_string(),
2415            category: ErrorCategory::Connection,
2416            recoverable: true,
2417        },
2418        other => UserFacingError {
2419            summary: "Model error".to_string(),
2420            message: other.to_string(),
2421            suggestion: String::new(),
2422            category: ErrorCategory::Internal,
2423            recoverable: false,
2424        },
2425    }
2426}
2427
2428#[cfg(test)]
2429mod tests {
2430    use super::*;
2431    use crate::domain::ToolCallId;
2432    use std::time::Duration;
2433
2434    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2435        EffectRunner::pair(PathBuf::from("/tmp"))
2436    }
2437
2438    #[test]
2439    fn new_child_suppresses_terminal_title() {
2440        // A subagent's child runner must not emit OSC 2 terminal titles —
2441        // otherwise they leak into a headless parent's stdout and corrupt
2442        // `--format json`/`text` output (caught during live headless testing).
2443        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2444        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2445        let tools = Arc::new(ToolRegistry::new());
2446        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2447        assert!(
2448            !child.terminal_title_enabled,
2449            "subagent child runner must suppress terminal-title escapes"
2450        );
2451    }
2452
2453    #[test]
2454    fn parse_prune_plan_extracts_json_amid_prose() {
2455        let plan = parse_prune_plan(
2456            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2457        )
2458        .expect("should parse");
2459        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2460        assert_eq!(plan.reason, "dupes");
2461    }
2462
2463    #[test]
2464    fn parse_prune_plan_handles_empty_and_garbage() {
2465        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2466            .expect("empty plan parses");
2467        assert!(empty.prune.is_empty());
2468        assert!(parse_prune_plan("no json here").is_none());
2469    }
2470
2471    #[test]
2472    fn memory_title_from_text_is_short_and_nonempty() {
2473        assert_eq!(
2474            memory_title_from_text("prefer ripgrep over grep"),
2475            "prefer ripgrep over grep"
2476        );
2477        assert_eq!(memory_title_from_text("   "), "memory");
2478        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2479        assert!(long.split_whitespace().count() <= 8);
2480    }
2481
2482    #[tokio::test]
2483    async fn dispatch_exit_is_noop_on_runner_state() {
2484        let (mut r, _rx) = runner();
2485        r.dispatch(Cmd::Exit);
2486        assert_eq!(r.scope_count(), 0);
2487    }
2488
2489    #[tokio::test]
2490    async fn dispatch_save_emits_session_saved() {
2491        let (mut r, mut rx) = runner();
2492        r.dispatch(Cmd::SaveConversation(
2493            crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2494        ));
2495        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2496            .await
2497            .expect("sender emits")
2498            .expect("channel alive");
2499        assert!(matches!(msg, Msg::SessionSaved));
2500    }
2501
2502    #[tokio::test]
2503    async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2504        let (mut r, mut rx) = runner();
2505        let t0 = std::time::Instant::now();
2506        r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2507        let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2508            .await
2509            .expect("sender emits")
2510            .expect("channel alive");
2511        assert!(matches!(msg, Msg::StatusDismiss));
2512        assert!(t0.elapsed() >= Duration::from_millis(25));
2513    }
2514
2515    #[test]
2516    fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2517        let msg = mcp_startup_msg("empty", true, Vec::new());
2518        assert!(matches!(
2519            msg,
2520            Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2521        ));
2522    }
2523
2524    #[test]
2525    fn mcp_startup_msg_reports_unstarted_server_as_error() {
2526        let msg = mcp_startup_msg("bad", false, Vec::new());
2527        assert!(matches!(
2528            msg,
2529            Msg::McpServerErrored { name, reason }
2530                if name == "bad" && reason.contains("failed to start")
2531        ));
2532    }
2533
2534    #[tokio::test]
2535    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2536        let (mut r, mut rx) = runner();
2537        let turn = TurnId(77);
2538        {
2539            let scope = r.scope_mut(turn);
2540            scope.spawn(async {
2541                std::future::pending::<()>().await;
2542            });
2543        }
2544        assert_eq!(r.scope_count(), 1);
2545
2546        let start = std::time::Instant::now();
2547        r.dispatch(Cmd::CancelScope(turn));
2548        assert_eq!(r.scope_count(), 0);
2549        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2550            .await
2551            .expect("bounded cancel should emit terminal message")
2552            .expect("channel alive");
2553        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2554        assert!(
2555            start.elapsed() < Duration::from_millis(500),
2556            "cancel terminal message took {:?}",
2557            start.elapsed()
2558        );
2559    }
2560
2561    #[tokio::test]
2562    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2563        // Regression (Axis 1 #9): if a turn's tasks complete and
2564        // `reap_empty_scopes` removes the now-empty scope before the user's
2565        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
2566        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
2567        // even when the scope is already gone.
2568        let (mut r, mut rx) = runner();
2569        let turn = TurnId(88);
2570        {
2571            let scope = r.scope_mut(turn);
2572            scope.spawn(async {}); // completes immediately
2573        }
2574        assert_eq!(r.scope_count(), 1);
2575
2576        // Let the task finish, then any dispatch reaps the now-empty scope.
2577        tokio::time::sleep(Duration::from_millis(20)).await;
2578        r.dispatch(Cmd::Exit);
2579        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2580
2581        // The scope is gone, but the reducer is still `Cancelling`: cancel must
2582        // still produce a terminal message.
2583        r.dispatch(Cmd::CancelScope(turn));
2584        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2585            .await
2586            .expect("cancel on a reaped scope must still emit a terminal message")
2587            .expect("channel alive");
2588        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2589    }
2590
2591    #[tokio::test]
2592    async fn dispatch_call_model_creates_scope() {
2593        let (mut r, _rx) = runner();
2594        let turn = TurnId(7);
2595        let request = crate::domain::ChatRequest {
2596            model_id: "test/m".to_string(),
2597            messages: vec![],
2598            system_prompt: String::new(),
2599            instructions: None,
2600            reasoning: crate::models::ReasoningLevel::Medium,
2601            temperature: 0.7,
2602            max_tokens: 4096,
2603            tools: vec![],
2604        };
2605        r.dispatch(Cmd::CallModel { turn, request });
2606        assert_eq!(r.scope_count(), 1);
2607    }
2608
2609    /// F12: after a spawned task completes (here via the
2610    /// no-ProviderFactory error path), the next `dispatch` call reaps
2611    /// the empty scope instead of leaving an orphan entry in the map.
2612    #[tokio::test]
2613    async fn empty_scopes_are_reaped_on_next_dispatch() {
2614        let (mut r, mut rx) = runner();
2615        let turn = TurnId(42);
2616        let request = crate::domain::ChatRequest {
2617            model_id: "test/m".to_string(),
2618            messages: vec![],
2619            system_prompt: String::new(),
2620            instructions: None,
2621            reasoning: crate::models::ReasoningLevel::Medium,
2622            temperature: 0.7,
2623            max_tokens: 4096,
2624            tools: vec![],
2625        };
2626        r.dispatch(Cmd::CallModel { turn, request });
2627        assert_eq!(r.scope_count(), 1);
2628
2629        // Runner has no provider bindings → dispatch_call_model hits
2630        // the "not wired" error path and emits UpstreamError, then the
2631        // spawned task returns. Drain that message so we know the task
2632        // ran to completion.
2633        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2634            .await
2635            .expect("upstream error arrived")
2636            .expect("channel alive");
2637        assert!(matches!(msg, Msg::UpstreamError { .. }));
2638
2639        // Give the JoinSet a tick to notice the task finished.
2640        tokio::task::yield_now().await;
2641
2642        // Any subsequent dispatch reaps the now-empty scope.
2643        r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2644        assert_eq!(
2645            r.scope_count(),
2646            0,
2647            "completed scope must be reaped on next dispatch"
2648        );
2649    }
2650
2651    #[tokio::test]
2652    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2653        let (mut r, mut rx) = runner();
2654        let turn = TurnId(7);
2655        let call_id = ToolCallId(1);
2656        let source = crate::models::tool_call::ToolCall {
2657            id: Some("c1".to_string()),
2658            function: crate::models::tool_call::FunctionCall {
2659                name: "read_file".to_string(),
2660                arguments: serde_json::json!({"path": "x"}),
2661            },
2662        };
2663        r.dispatch(Cmd::ExecuteTool {
2664            turn,
2665            call_id,
2666            source,
2667            model_id: "ollama/test".to_string(),
2668            safety_mode: crate::runtime::SafetyMode::Ask,
2669            intent: None,
2670        });
2671        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2672            .await
2673            .expect("some msg")
2674            .expect("channel alive");
2675        assert!(matches!(
2676            first,
2677            Msg::ToolStarted {
2678                turn: t,
2679                call_id: c,
2680            } if t == turn && c == call_id
2681        ));
2682    }
2683
2684    #[tokio::test]
2685    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2686        let (mut r, _rx) = runner();
2687        let turn = TurnId(9);
2688        r.dispatch(Cmd::CallModel {
2689            turn,
2690            request: crate::domain::ChatRequest {
2691                model_id: "m".to_string(),
2692                messages: vec![],
2693                system_prompt: String::new(),
2694                instructions: None,
2695                reasoning: crate::models::ReasoningLevel::Medium,
2696                temperature: 0.7,
2697                max_tokens: 4096,
2698                tools: vec![],
2699            },
2700        });
2701        assert_eq!(r.scope_count(), 1);
2702
2703        r.dispatch(Cmd::CancelScope(turn));
2704        assert_eq!(r.scope_count(), 0);
2705    }
2706
2707    #[tokio::test]
2708    async fn shutdown_drains_pending_saves() {
2709        let (mut r, _rx) = runner();
2710        for _ in 0..5 {
2711            r.dispatch(Cmd::SaveConversation(
2712                crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2713            ));
2714        }
2715        // Shutdown waits for all five to complete (should be instant).
2716        let start = std::time::Instant::now();
2717        r.shutdown().await;
2718        assert!(start.elapsed() < Duration::from_secs(2));
2719    }
2720}