mermaid_cli/app/run.rs
1//! The ~30-line main loop.
2//!
3//! Single entry point that composes crossterm events, the reducer,
4//! and the effect runner:
5//!
6//! ```text
7//! crossterm events ──┐
8//! ├── tokio::select! ── Msg ── update(State, Msg) ── (State, Vec<Cmd>) ── EffectRunner::dispatch ──┐
9//! effect results ──┤ │
10//! │ ▲ │
11//! tick ──┘ │ │
12//! └─────── Msg back ◄──────┘
13//! ```
14//!
15//! No parallel event loops, no observer callbacks, no polling. One
16//! select!, one reducer call per message, effects dispatched into
17//! structured concurrency per turn.
18
19use std::collections::VecDeque;
20use std::path::PathBuf;
21
22use anyhow::Result;
23use crossterm::event::EventStream;
24use futures::{FutureExt, StreamExt};
25use ratatui::layout::Rect;
26use tokio::time::{Duration, interval};
27
28use crate::app::event_source::coalesce_key_burst;
29use crate::app::lifecycle::RuntimeLifecycle;
30use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder, SessionHeader};
31use crate::app::terminal::TerminalGuard;
32use crate::effect::EffectRunner;
33use crate::providers::ToolRegistry;
34use crate::render::{RenderCache, render};
35use mermaid_domain::Config;
36use mermaid_domain::ConversationHistory;
37use mermaid_domain::{Cmd, Msg, RuntimeSignal, State, update};
38
39/// Options for `run_interactive_with`. Added so new flags land without
40/// reshuffling positional args.
41///
42/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
43/// Debug. The bigger picture is that nothing prints these — they're an
44/// argument bundle, not telemetry.
45#[derive(Default)]
46pub struct InteractiveOptions {
47 /// Optional recorder for `--record <file>` JSONL capture.
48 pub recorder: Option<Recorder>,
49 /// Optional conversation to seed the session with (e.g. from
50 /// `--continue` or `--sessions`). When `Some`, the seeded history
51 /// replaces `State::session.conversation` before the first frame.
52 pub seed_conversation: Option<ConversationHistory>,
53}
54
55/// Resolve `user@host` for the status bar, once, at startup.
56///
57/// Lives in the shell rather than beside the `RenderCache` fields it fills:
58/// `src/render` is covered by the layering guard, so an environment read
59/// anywhere under it is impurity in a tree that must stay a pure function of
60/// `State`. Reading here and passing the result down is the whole difference.
61///
62/// `HOSTNAME`/`HOST` and `USER`/`USERNAME` are checked in that order because
63/// the first of each pair is the Unix spelling and the second the Windows one;
64/// the final fallbacks keep the status bar rendering something sane when a
65/// stripped environment provides neither.
66fn host_identity() -> (String, String) {
67 let hostname = std::env::var("HOSTNAME")
68 .or_else(|_| std::env::var("HOST"))
69 .unwrap_or_else(|_| "localhost".to_string());
70 let username = std::env::var("USER")
71 .or_else(|_| std::env::var("USERNAME"))
72 .unwrap_or_else(|_| "user".to_string());
73 (hostname, username)
74}
75
76/// Interactive TUI main loop with explicit options. `recorder` (if
77/// provided) appends one JSONL line per reducer input to the file for
78/// debugging / replay.
79///
80/// # Errors
81///
82/// Setting up the terminal, opening the recorder when one is requested, and a
83/// failure in the main loop or in the shutdown that follows it. A model or
84/// tool that fails mid-session is not among them: those surface in the
85/// transcript and the loop continues, which is what makes a session survivable.
86#[expect(
87 clippy::too_many_lines,
88 reason = "predates the lint; see .github/baselines/expect_budget.txt"
89)]
90pub async fn run_interactive_with(
91 mut config: Config,
92 cwd: PathBuf,
93 model_id: String,
94 mut opts: InteractiveOptions,
95) -> Result<()> {
96 // One startup clock read, shared by `State::new` and the recording
97 // header: replay seeds `State::new` with the recorded value and gets the
98 // same initial conversation id/title.
99 let startup_now = chrono::Local::now();
100 // Fold enabled plugins' MCP servers + agent types into the merged config
101 // BEFORE anything consumes it (State::new seeds server rows, the
102 // recording header captures the merged config — replay-faithful, and the
103 // provider factory + tool registry see the same view).
104 let plugin_assets = crate::app::plugin_assets::load();
105 let plugin_warnings = crate::app::plugin_assets::apply(&mut config, &plugin_assets);
106 let mut state = State::new(
107 config.clone(),
108 cwd.clone(),
109 model_id.clone(),
110 startup_now,
111 std::env::temp_dir(),
112 );
113 let seed = opts.seed_conversation.take();
114 if let Some(r) = opts.recorder.as_mut() {
115 // The header makes a recording self-contained: `--replay` rebuilds
116 // the initial State from it (config, model, cwd, seed) without
117 // reading this machine's live config. Written before the first Msg
118 // so even a crashed session leaves a parseable log.
119 r.record_header(&SessionHeader {
120 format: RECORDING_FORMAT_VERSION,
121 ts: startup_now,
122 model_id: model_id.clone(),
123 cwd: cwd.clone(),
124 config: config.clone(),
125 seed_conversation: seed.clone(),
126 })?;
127 }
128 if let Some(history) = seed {
129 // `--continue` / `--resume` seed — shared with `--replay` via
130 // `State::seed_conversation` so both build the same starting state.
131 state.seed_conversation(history);
132 }
133 state
134 .ui
135 .pending_msgs
136 .push_back(Msg::SessionProvenanceResolved(
137 crate::session::probe_session_provenance(&cwd),
138 ));
139 // NO_COLOR (https://no-color.org): present and non-empty disables all
140 // color. Read once here — the reducer never touches the environment; the
141 // render layer resolves `Theme::plain()` off this flag.
142 state.ui.no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
143 // Skills load once at startup (authored artifacts, no watcher); the config
144 // watcher below keeps only instructions/memory fresh.
145 state.skills = crate::app::skills::load(&cwd);
146 // Plugin prompt commands: same restart-to-refresh policy as skills.
147 state.plugin_commands = plugin_assets.commands;
148 for warning in plugin_warnings {
149 state
150 .ui
151 .pending_msgs
152 .push_back(Msg::TransientStatus { text: warning });
153 }
154 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
155 let tools = ToolRegistry::build(
156 &config,
157 crate::providers::TuiMode::Interactive,
158 providers.clone(),
159 );
160 if let Some(capabilities) = tools.web_capabilities()
161 && let Some(text) = web_capabilities_notice(&config, capabilities)
162 {
163 state
164 .ui
165 .pending_msgs
166 .push_back(Msg::TransientStatus { text });
167 }
168 let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
169 // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
170 // escalations) pause and prompt instead of erroring out, and inline
171 // `ask_user_question` prompts so the model can ask the user structured
172 // questions mid-run instead of proceeding without them.
173 let mut runner = runner
174 .with_interactive_approvals()
175 .with_interactive_questions();
176 // Keep instructions/memory fresh via the background config watcher (#45):
177 // it emits Msg::InstructionsChanged/MemoryChanged on change, so the reducer
178 // reads them as injected data and never does the refresh I/O inline.
179 runner.spawn_config_watcher(cwd.clone(), config.memory.clone());
180 let mut terminal = Some(TerminalGuard::setup()?);
181 let (hostname, username) = host_identity();
182 let mut rstate = RenderCache::new(hostname, username);
183 // `Option` because the $EDITOR compose round-trip must DROP the stream
184 // (its reader thread holds crossterm's internal reader mutex) before
185 // suspending, and build a fresh one after — same lifecycle dance as
186 // `terminal` above.
187 let mut events = Some(EventStream::new());
188 let mut lifecycle = RuntimeLifecycle::new();
189 let mut tick = interval(Duration::from_millis(16));
190 let mut recorder = opts.recorder;
191
192 // Boot effects: MCP server init (if configured). Instructions/memory are
193 // loaded by the config watcher started above (#45), not here.
194 for cmd in bootstrap_cmds(&config, &state.session.conversation.id) {
195 runner.dispatch(cmd);
196 }
197 // A resumed session may carry an in-flight checklist; hand it to the
198 // TaskBroker (tool-side truth) so the first task tool call of the new
199 // process starts from the restored list instead of an empty one.
200 if !state.session.conversation.tasks.tasks.is_empty() {
201 runner.dispatch(mermaid_domain::Cmd::SyncTaskStore(
202 state.session.conversation.tasks.clone(),
203 ));
204 }
205
206 // Which `select!` arm fired. Terminal events are handled *after* the
207 // select! returns so the paste-coalescing drain can borrow `events`
208 // again without tripping the borrow checker.
209 //
210 // `Msg` is the large variant, but this enum lives on the stack for one
211 // loop iteration and `Msg` is passed by value everywhere already —
212 // boxing it would add a per-event heap alloc on the hot input path.
213 #[expect(clippy::large_enum_variant)]
214 enum Sel {
215 Msg(Option<Msg>),
216 Term(Option<Result<crossterm::event::Event, std::io::Error>>),
217 }
218
219 // Msgs produced ahead of time — e.g. a non-paste event drained while
220 // coalescing a key burst. Processed before pulling the next event.
221 let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
222
223 // Main loop. A fatal error inside the loop is captured here and returned
224 // AFTER the orderly-shutdown path below, so a draw failure can't skip MCP
225 // child cleanup / pending-save drains (the terminal is still restored by
226 // `TerminalGuard::Drop` regardless).
227 let mut exit_result: Result<()> = Ok(());
228 // Last-seen `full_redraw_seq`. When the reducer bumps it (shell command
229 // finished, Ctrl+L), `Terminal::clear()` resets ratatui's back buffer so
230 // the next draw repaints every cell — the only way to overwrite bytes
231 // some other process wrote directly to the tty (ghost cells).
232 let mut seen_redraw_seq = state.ui.full_redraw_seq;
233 loop {
234 // Render the current state. ratatui's draw closure captures
235 // &state, so we don't thread &mut state through the renderer.
236 {
237 let term = terminal
238 .as_mut()
239 .expect("terminal guard is alive while the render loop runs")
240 .inner_mut();
241 if state.ui.full_redraw_seq != seen_redraw_seq {
242 seen_redraw_seq = state.ui.full_redraw_seq;
243 // NOT `Terminal::clear()`: it snapshots the cursor with an
244 // ESC[6n round-trip, and the reply never arrives — the
245 // `EventStream` reader thread is parked holding crossterm's
246 // internal reader mutex and swallows it — so the query dies
247 // fatally after crossterm's 2s deadline. `resize()` to the
248 // current size performs the same full clear + back-buffer
249 // reset for a Fullscreen viewport without querying the tty.
250 let repaint = term
251 .size()
252 .and_then(|size| term.resize(Rect::new(0, 0, size.width, size.height)));
253 if let Err(err) = repaint {
254 exit_result = Err(err.into());
255 break;
256 }
257 }
258 if let Err(err) = term.draw(|f| render(&state, &mut rstate, f)) {
259 exit_result = Err(err.into());
260 break;
261 }
262 }
263
264 // Drain any msgs queued by a prior burst-coalesce before blocking
265 // on the next event.
266 let msg = if let Some(queued) = pending_msgs.pop_front() {
267 Some(queued)
268 } else {
269 let selected = tokio::select! {
270 // Fair (unbiased) polling. With `biased;`, the hot `msg_rx`
271 // arm would always win under sustained streaming and starve
272 // terminal input + OS signals (#112). Fair selection still
273 // drains streaming promptly — it's almost always ready — while
274 // guaranteeing the input/signal/tick arms get serviced too.
275 //
276 // Effect results (streaming chunks, tool output, …).
277 m = msg_rx.recv() => Sel::Msg(m),
278 // Crossterm events. Handled below, outside the select!, so
279 // coalescing can re-borrow `events`.
280 e = events.as_mut().expect("event stream is alive while the loop runs").next() => Sel::Term(e),
281 // OS lifecycle signals. A typed Ctrl+C in raw mode is handled
282 // by the crossterm branch above; this covers SIGINT/SIGTERM/
283 // SIGHUP delivered externally.
284 s = lifecycle.next_msg() => Sel::Msg(s),
285 // Tick — drives elapsed-time displays + self-dismissing status
286 // lines without busy-waiting.
287 _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
288 };
289
290 match selected {
291 Sel::Msg(m) => m,
292 Sel::Term(Some(Ok(evt))) => {
293 if let crossterm::event::Event::Mouse(m) = &evt {
294 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
295 let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
296 match m.kind {
297 // F13: Ctrl+Click a chat image tile opens it via
298 // the system viewer. The screen→image mapping
299 // lives in ChatState (the render layer).
300 MEK::Down(MouseButton::Left) if ctrl => rstate
301 .chat
302 .find_image_at_screen_pos(m.row)
303 .map(|target| Msg::OpenImageAt {
304 message_index: target.message_index,
305 image_index: target.image_index,
306 image_number: target.image_number,
307 }),
308 // Plain (no-modifier) left drag selects chat text.
309 // Handled render-side so wheel-scroll + Ctrl+Click
310 // keep working; on release we copy the selection.
311 MEK::Down(MouseButton::Left) => {
312 rstate.chat.begin_selection(m.row, m.column);
313 None
314 },
315 MEK::Drag(MouseButton::Left) => {
316 rstate.chat.update_selection(m.row, m.column);
317 None
318 },
319 MEK::Up(MouseButton::Left) => {
320 // A drag only *selects* (the highlight persists);
321 // copying is an explicit action (Ctrl+Shift+C).
322 // Auto-copying on release would silently clobber
323 // the user's clipboard.
324 None
325 },
326 MEK::ScrollUp => Some(Msg::MouseScroll {
327 delta: mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16,
328 }),
329 MEK::ScrollDown => Some(Msg::MouseScroll {
330 delta: -(mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16),
331 }),
332 _ => None,
333 }
334 } else {
335 // Non-mouse event. Ctrl+Shift+C copies the current chat
336 // selection — the explicit copy step after a drag-select.
337 // Because the app holds the mouse, the terminal has no
338 // selection of its own and passes the shortcut through.
339 // The SHIFT bit only arrives when the kitty keyboard
340 // protocol was negotiated at setup (TerminalGuard); on
341 // legacy terminals Ctrl+Shift+C is transmitted as the
342 // identical byte 0x03 as Ctrl+C — physically
343 // indistinguishable — so there it falls through to the
344 // reducer's Ctrl+C handling (press-twice-to-exit keeps
345 // a stray copy-chord harmless).
346 if let crossterm::event::Event::Key(k) = &evt
347 && k.kind == crossterm::event::KeyEventKind::Press
348 && k.modifiers
349 .contains(crossterm::event::KeyModifiers::CONTROL)
350 && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
351 && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
352 {
353 // Route the copy through the reducer (#18): the
354 // selection lives in the render layer, but emitting a
355 // Msg keeps the clipboard side effect recorded +
356 // replayable instead of dispatched out-of-band.
357 rstate
358 .chat
359 .selected_text()
360 .filter(|t| !t.is_empty())
361 .map(Msg::CopySelection)
362 } else {
363 // Coalesce a paste burst (crossterm 0.29 doesn't
364 // deliver Event::Paste on the Windows console — a
365 // paste arrives as a flood of Char/Enter key events).
366 // The drain pulls every immediately-available event
367 // so the whole block lands as one atomic Msg::Paste.
368 let (primary, trailing) = coalesce_key_burst(evt, || {
369 events
370 .as_mut()
371 .expect("event stream is alive while the loop runs")
372 .next()
373 .now_or_never()
374 .flatten()
375 .and_then(|r| r.ok())
376 });
377 for queued in trailing {
378 pending_msgs.push_back(queued);
379 }
380 primary
381 }
382 }
383 },
384 Sel::Term(Some(Err(error))) => {
385 tracing::warn!(error = %error, "terminal event stream failed");
386 None
387 },
388 Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
389 }
390 };
391
392 let Some(msg) = msg else { continue };
393
394 // Inject the wall clock as data (Cause 3): one stamp per tick, shared
395 // by the recording and the reducer. The recorded `ts` IS the
396 // `state.now` this Msg was reduced under, so `--replay` folds the
397 // same log by stamping each entry's `ts` here and recomputes the
398 // exact same states.
399 let now = chrono::Local::now();
400
401 // Optional recording: one JSONL line per Msg, before the
402 // reducer runs so the log captures even no-op inputs.
403 if let Some(r) = recorder.as_mut()
404 && let Err(err) = r.record_msg(now, &msg)
405 {
406 tracing::warn!(error = %err, "recorder: failed to record message; --replay may be non-deterministic");
407 }
408
409 state.now = now;
410 let (new_state, cmds) = update(state, msg);
411 state = new_state;
412 // `ComposeInEditor` is run-loop-owned (it suspends the terminal +
413 // event stream, which only this loop holds); everything else goes to
414 // the effect runner. At most one compose per reducer step by
415 // construction (single Ctrl+O / /editor arm).
416 let mut compose_draft: Option<String> = None;
417 for cmd in cmds {
418 if let Cmd::ComposeInEditor { text } = cmd {
419 compose_draft = Some(text);
420 } else {
421 runner.dispatch(cmd);
422 }
423 }
424 if let Some(draft) = compose_draft {
425 match crate::app::editor::compose_in_editor(&mut terminal, &mut events, draft).await {
426 // Through pending_msgs, so the result flows through the
427 // recorder like any input — --replay never launches an editor.
428 Ok(msg) => pending_msgs.push_back(msg),
429 Err(err) => {
430 exit_result = Err(err);
431 break;
432 },
433 }
434 }
435
436 if state.should_exit {
437 break;
438 }
439 }
440
441 // Seal the recording with a fingerprint of the final session, so a
442 // future `--replay` can verify its fold reproduces what this live
443 // session actually saw — not merely that the fold is self-consistent.
444 // (Wall-clock read is fine here: we're outside the reducer.)
445 if let Some(r) = recorder.as_mut()
446 && let Err(err) = r.record_trailer(chrono::Local::now(), &state.session)
447 {
448 tracing::warn!(error = %err, "recorder: failed to write replay trailer");
449 }
450
451 // Restore the user's terminal before async shutdown. Shutdown can
452 // wait on pending saves / cancelled scopes for a bounded period;
453 // keeping raw mode + mouse capture alive during that wait makes
454 // Ctrl+C feel ignored and can leak mouse escape sequences into
455 // the shell if the user keeps interacting.
456 drop(events);
457 if let Some(mut terminal) = terminal.take() {
458 terminal.restore_now();
459 }
460
461 // Orderly shutdown — wait for any pending saves / scope cleanup. Runs even
462 // when the loop broke on a draw error, so MCP children are reaped cleanly.
463 runner.shutdown().await;
464 exit_result
465}
466
467/// Commands dispatched on startup before the first iteration of the
468/// loop. Fires MCP init (if configured) and materializes the session's
469/// scratch directory. Instructions/memory are loaded by the config
470/// watcher (#45), not here.
471fn bootstrap_cmds(config: &Config, session_id: &str) -> Vec<Cmd> {
472 // Instructions/memory load + stay fresh via the config watcher (#45),
473 // started in `run_interactive_with`.
474 let mut cmds = Vec::new();
475 if !config.mcp_servers.is_empty() {
476 cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
477 }
478 // Every session gets a scratch dir — `session_id` is captured AFTER any
479 // `--continue`/`--resume` seed, so a resumed session adopts the dir
480 // keyed by its restored conversation id.
481 cmds.push(Cmd::EnsureScratchpad {
482 session_id: session_id.to_string(),
483 });
484 cmds
485}
486
487/// One startup-visible summary built from the exact capability resolution used
488/// by the registry and subagents. This makes backend/trust routing explicit in
489/// the TUI without re-reading credentials or probing platform viability.
490///
491/// Returns `None` only for the boring case — every capability resolved AND
492/// every one of them terminates on this machine — so a healthy sovereign
493/// startup stays quiet. Silence therefore means "working and local"; anything
494/// else speaks. Availability alone is deliberately NOT the gate: a working
495/// cloud backend is exactly what a user needs told, so gating on viability
496/// would mute the disclosure precisely when traffic is leaving the machine.
497fn web_capabilities_notice(
498 config: &Config,
499 capabilities: &crate::providers::tool::web::WebCapabilities,
500) -> Option<String> {
501 use crate::providers::tool::web::Egress;
502
503 if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
504 return Some(format!(
505 "Web egress disabled by safety.network = \"deny\" (selected fetch backend: {}; selected search backend: {}).",
506 capabilities.fetch.backend, capabilities.search.backend
507 ));
508 }
509
510 let all = [
511 ("fetch", &capabilities.fetch),
512 ("search", &capabilities.search),
513 ];
514 let degraded = all
515 .into_iter()
516 .filter(|(_, status)| !status.available)
517 .collect::<Vec<_>>();
518 let leaves_machine = all
519 .iter()
520 .any(|(_, status)| status.egress == Egress::OffMachine);
521 if degraded.is_empty() && !leaves_machine {
522 return None;
523 }
524
525 // Headline stays one line per capability: backend + availability, and the
526 // trust destination ONLY where it means something. An unavailable backend
527 // routes nowhere, so naming its destination there is noise that also
528 // strands the remediation text mid-sentence.
529 let headline = |name: &str, status: &crate::providers::tool::web::WebCapabilityStatus| {
530 if status.available {
531 format!(
532 "{name}: {} (available; {})",
533 status.backend, status.trust_destination
534 )
535 } else {
536 format!("{name}: {} (unavailable)", status.backend)
537 }
538 };
539
540 // Remediation prose is a paragraph, not a parenthetical — give each
541 // degraded capability its own line below the headline. The marker is a
542 // `-` bullet, not leading whitespace: the transcript renderer re-wraps
543 // system notices word by word (`wrap_text_with_indent`), so an indent is
544 // dropped and the detail lines would be indistinguishable from the
545 // wrapped headline. A glyph is a word, so it survives.
546 let mut notice = format!(
547 "Web capabilities - {}; {}.",
548 headline("fetch", &capabilities.fetch),
549 headline("search", &capabilities.search)
550 );
551 for (name, status) in degraded {
552 let reason = status
553 .reason
554 .as_deref()
555 .map(mermaid_model::utils::redact_secrets)
556 .unwrap_or_else(|| "backend initialization failed".to_string());
557 let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
558 let reason = mermaid_model::utils::truncate_middle_bytes(&reason, 240)
559 .split_whitespace()
560 .collect::<Vec<_>>()
561 .join(" ");
562 notice.push_str(&format!("\n- {name}: {reason}"));
563 }
564 Some(notice)
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 #[test]
572 fn bootstrap_always_ensures_the_session_scratchpad() {
573 // Instructions/memory load via the config watcher (#45), not
574 // bootstrap; with no MCP servers configured, only the scratchpad
575 // ensure remains — keyed by the caller's session id.
576 let cmds = bootstrap_cmds(&Config::default(), "sess-1");
577 assert_eq!(cmds.len(), 1);
578 assert!(
579 cmds.iter().any(
580 |c| matches!(c, Cmd::EnsureScratchpad { session_id } if session_id == "sess-1")
581 )
582 );
583 }
584
585 #[test]
586 fn bootstrap_skips_mcp_init_when_no_servers_configured() {
587 let cmds = bootstrap_cmds(&Config::default(), "sess-1");
588 assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
589 }
590
591 #[test]
592 fn bootstrap_includes_mcp_init_when_servers_configured() {
593 let mut cfg = Config::default();
594 cfg.mcp_servers.insert(
595 "example".to_string(),
596 mermaid_domain::McpServerConfig {
597 command: "echo".to_string(),
598 args: vec![],
599 env: std::collections::HashMap::new(),
600 ..Default::default()
601 },
602 );
603 let cmds = bootstrap_cmds(&cfg, "sess-1");
604 assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
605 }
606
607 /// Statuses are built by hand rather than via `WebCapabilities::resolve`
608 /// so the notice's formatting is asserted independently of whichever
609 /// backends happen to be viable on the test host.
610 fn capabilities(
611 fetch: crate::providers::tool::web::WebCapabilityStatus,
612 search: crate::providers::tool::web::WebCapabilityStatus,
613 ) -> crate::providers::tool::web::WebCapabilities {
614 crate::providers::tool::web::WebCapabilities::from_statuses_for_test(fetch, search)
615 }
616
617 fn available(
618 backend: &'static str,
619 trust_destination: &'static str,
620 egress: crate::providers::tool::web::Egress,
621 ) -> crate::providers::tool::web::WebCapabilityStatus {
622 crate::providers::tool::web::WebCapabilityStatus {
623 available: true,
624 backend,
625 trust_destination,
626 egress,
627 reason: None,
628 }
629 }
630
631 fn unavailable(
632 backend: &'static str,
633 trust_destination: &'static str,
634 egress: crate::providers::tool::web::Egress,
635 reason: &str,
636 ) -> crate::providers::tool::web::WebCapabilityStatus {
637 crate::providers::tool::web::WebCapabilityStatus {
638 available: false,
639 backend,
640 trust_destination,
641 egress,
642 reason: Some(reason.to_string()),
643 }
644 }
645
646 /// The two sovereign defaults, spelled once: fetch straight off this
647 /// machine, search via the locally managed SearXNG process.
648 fn local_fetch() -> crate::providers::tool::web::WebCapabilityStatus {
649 available(
650 "native",
651 "direct from this machine",
652 crate::providers::tool::web::Egress::OnMachine,
653 )
654 }
655
656 fn local_search() -> crate::providers::tool::web::WebCapabilityStatus {
657 available(
658 "managed_searxng",
659 "local managed process",
660 crate::providers::tool::web::Egress::OnMachine,
661 )
662 }
663
664 #[test]
665 fn web_capability_notice_stays_silent_when_everything_resolved_and_local() {
666 let config = Config::default();
667 let capabilities = capabilities(local_fetch(), local_search());
668 assert_eq!(web_capabilities_notice(&config, &capabilities), None);
669 }
670
671 /// The regression this gate exists to prevent: a WORKING cloud backend is
672 /// the case a sovereignty-focused tool most needs to disclose, so
673 /// viability alone must never buy silence.
674 #[test]
675 fn web_capability_notice_discloses_working_cloud_egress() {
676 let config = Config::default();
677 let capabilities = capabilities(
678 local_fetch(),
679 available(
680 "ollama_cloud",
681 "Ollama Cloud",
682 crate::providers::tool::web::Egress::OffMachine,
683 ),
684 );
685 let notice =
686 web_capabilities_notice(&config, &capabilities).expect("cloud egress must disclose");
687 assert!(
688 notice.contains("search: ollama_cloud (available; Ollama Cloud)"),
689 "{notice}"
690 );
691 // Nothing is broken, so nothing earns a remediation line.
692 assert!(!notice.contains('\n'), "{notice}");
693 }
694
695 /// An operator-supplied SearXNG URL cannot be proven to be loopback, so it
696 /// discloses like any other off-machine destination.
697 #[test]
698 fn web_capability_notice_discloses_configured_searxng_endpoint() {
699 let config = Config::default();
700 let capabilities = capabilities(
701 local_fetch(),
702 available(
703 "searxng",
704 "configured SearXNG instance",
705 crate::providers::tool::web::Egress::OffMachine,
706 ),
707 );
708 let notice =
709 web_capabilities_notice(&config, &capabilities).expect("configured endpoint discloses");
710 assert!(notice.contains("configured SearXNG instance"), "{notice}");
711 }
712
713 #[test]
714 fn web_capability_notice_gives_every_degraded_capability_its_own_line() {
715 let config = Config::default();
716 let capabilities = capabilities(
717 unavailable(
718 "native",
719 "direct from this machine",
720 crate::providers::tool::web::Egress::OnMachine,
721 "TLS backend failed to initialize",
722 ),
723 unavailable(
724 "managed_searxng",
725 "local managed process",
726 crate::providers::tool::web::Egress::OnMachine,
727 "no sovereign SearXNG bundle is available for this platform",
728 ),
729 );
730 let notice = web_capabilities_notice(&config, &capabilities).expect("both degraded");
731 let lines = notice.lines().collect::<Vec<_>>();
732 assert_eq!(lines.len(), 3, "{notice}");
733 assert!(lines[1].starts_with("- fetch: TLS backend"), "{notice}");
734 assert!(lines[2].starts_with("- search: no sovereign"), "{notice}");
735 }
736
737 #[test]
738 fn web_capability_notice_discloses_shared_backend_and_trust_routing() {
739 let config = Config::default();
740 let capabilities = capabilities(
741 local_fetch(),
742 unavailable(
743 "managed_searxng",
744 "local managed process",
745 crate::providers::tool::web::Egress::OnMachine,
746 "no sovereign SearXNG bundle is available for this platform",
747 ),
748 );
749 let notice = web_capabilities_notice(&config, &capabilities).expect("degraded search");
750 // The healthy capability still discloses where its traffic goes.
751 assert!(notice.contains("fetch: native (available"), "{notice}");
752 assert!(notice.contains("direct from this machine"), "{notice}");
753 assert!(
754 notice.contains("search: managed_searxng (unavailable)"),
755 "{notice}"
756 );
757 }
758
759 #[test]
760 fn web_capability_notice_moves_remediation_off_the_headline() {
761 let config = Config::default();
762 let capabilities = capabilities(
763 local_fetch(),
764 unavailable(
765 "managed_searxng",
766 "local managed process",
767 crate::providers::tool::web::Egress::OnMachine,
768 "no sovereign SearXNG bundle is available for this platform (windows/x86_64).\n Configure `[web] search_backend = \"ollama\"`.",
769 ),
770 );
771 let notice = web_capabilities_notice(&config, &capabilities).expect("degraded search");
772 let (headline, detail) = notice.split_once('\n').expect("detail line");
773 // The unavailable backend routes nowhere, so its trust destination is
774 // not named — and the paragraph never lands mid-parenthetical.
775 assert!(!headline.contains("local managed process"), "{headline}");
776 assert!(!headline.contains("SearXNG bundle"), "{headline}");
777 assert_eq!(
778 detail,
779 "- search: no sovereign SearXNG bundle is available for this platform (windows/x86_64). Configure `[web] search_backend = \"ollama\"`."
780 );
781 }
782
783 #[test]
784 fn web_capability_notice_honors_global_network_denial() {
785 let mut config = Config::default();
786 config.safety.network = mermaid_domain::NetworkPolicy::Deny;
787 // Denial reports regardless of viability or locality — both backends
788 // resolve here, and both stay on this machine.
789 let capabilities = capabilities(local_fetch(), local_search());
790 let notice = web_capabilities_notice(&config, &capabilities).expect("denial always shows");
791 assert!(notice.contains("Web egress disabled"), "{notice}");
792 assert!(notice.contains("fetch backend: native"), "{notice}");
793 assert!(
794 notice.contains("search backend: managed_searxng"),
795 "{notice}"
796 );
797 }
798}