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