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