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