Skip to main content

mermaid_cli/effect/
mod.rs

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