Skip to main content

mermaid_cli/app/
run_non_interactive.rs

1//! Headless driver for `mermaid run <prompt>`.
2//!
3//! Same reducer + same effect runner + same providers + same tools
4//! as the interactive path. Differences: no `TerminalGuard`, no
5//! crossterm events, no tick timer, no render. One synthetic
6//! `Msg::SubmitPrompt` seeds the reducer; the loop spins until
7//! `state.turn == Idle` and the queue is empty.
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13use tokio::time::timeout;
14
15use crate::app::Config;
16use crate::app::lifecycle::RuntimeLifecycle;
17use crate::cli::OutputFormat;
18use crate::domain::{Msg, State, TurnState, update};
19use crate::effect::EffectRunner;
20use crate::models::MessageRole;
21use crate::providers::ToolRegistry;
22
23/// Output shape the CLI prints.
24#[derive(Debug, Default)]
25pub struct RunResult {
26    pub response: String,
27    pub reasoning: Option<String>,
28    pub total_tokens: usize,
29    pub errors: Vec<String>,
30}
31
32/// Per-invocation options for `run_non_interactive`.
33///
34/// Added as a struct so new flags can land without reshuffling the
35/// function's positional args. All fields default to "no change".
36#[derive(Debug, Default, Clone)]
37pub struct RunOptions {
38    /// When true, register an empty `ToolRegistry` — the model sees no
39    /// tools and can't take actions. Dry-run mode for
40    /// `mermaid run --no-execute`.
41    pub no_execute: bool,
42    /// Durable runtime task that owns this run, when launched through
43    /// `mermaidd` or `mermaid run` task creation.
44    pub task_id: Option<String>,
45}
46
47/// Drive one prompt to completion. Bounded by a generous 20-minute
48/// wall-clock so a runaway model doesn't hang a script.
49pub async fn run_non_interactive(
50    config: Config,
51    cwd: PathBuf,
52    model_id: String,
53    prompt: String,
54) -> Result<RunResult> {
55    run_non_interactive_with(config, cwd, model_id, prompt, RunOptions::default()).await
56}
57
58/// Same as `run_non_interactive` but with explicit per-call options.
59/// Kept separate so existing call sites keep compiling unchanged.
60pub async fn run_non_interactive_with(
61    config: Config,
62    cwd: PathBuf,
63    model_id: String,
64    prompt: String,
65    opts: RunOptions,
66) -> Result<RunResult> {
67    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
68    // F6 `--no-execute`: build an empty tool registry so the model can
69    // plan but never act. MCP init below is also skipped to match.
70    let tools = if opts.no_execute {
71        std::sync::Arc::new(ToolRegistry::new())
72    } else {
73        ToolRegistry::build(
74            &config,
75            crate::providers::TuiMode::Headless,
76            providers.clone(),
77        )
78    };
79    let (mut runner, mut msg_rx) =
80        EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
81    runner = runner.without_terminal_title();
82
83    let mut state = State::new(config.clone(), cwd, model_id);
84    let mut lifecycle = RuntimeLifecycle::new();
85
86    // Bootstrap effects (MCP init) before the first prompt. The
87    // instructions refresh used to dispatch here too, but F8 moved it
88    // inline into `handle_submit_prompt` (synchronous stat + optional
89    // small read) so the very first call actually sees the current
90    // MERMAID.md — previously the dispatch race meant run #1 missed
91    // edits and only run #2 picked them up.
92    //
93    // Skip MCP init when `--no-execute` — MCP tools would advertise
94    // through the registry we just emptied, so spinning up their
95    // processes is wasted work.
96    if !config.mcp_servers.is_empty() && !opts.no_execute {
97        runner.dispatch(crate::domain::Cmd::InitMcpServers(
98            config.mcp_servers.clone(),
99        ));
100    }
101
102    // Seed the turn.
103    let seed = Msg::SubmitPrompt {
104        text: prompt,
105        attachment_ids: vec![],
106    };
107    // Inject the wall clock as data so the reducer stays pure (Cause 3).
108    state.now = chrono::Local::now();
109    let (new_state, cmds) = update(state, seed);
110    state = new_state;
111    for cmd in cmds {
112        runner.dispatch(cmd);
113    }
114
115    let deadline = Duration::from_secs(20 * 60);
116
117    let drive = async {
118        while !matches!(state.turn, TurnState::Idle) || !state.ui.queued_messages.is_empty() {
119            let msg = tokio::select! {
120                m = msg_rx.recv() => match m {
121                    Some(m) => m,
122                    None => break,
123                },
124                s = lifecycle.next_msg() => match s {
125                    Some(s) => s,
126                    None => continue,
127                },
128            };
129            state.now = chrono::Local::now();
130            let (new_state, cmds) = update(state, msg);
131            state = new_state;
132            for cmd in cmds {
133                runner.dispatch(cmd);
134            }
135            if state.should_exit {
136                break;
137            }
138        }
139        state
140    };
141
142    let final_state = timeout(deadline, drive).await.map_err(|_| {
143        anyhow::anyhow!(
144            "non-interactive run exceeded {} seconds",
145            deadline.as_secs()
146        )
147    })?;
148
149    runner.shutdown().await;
150    Ok(build_result(&final_state))
151}
152
153/// Walk the committed message history and pull out the last
154/// assistant response + any errors encountered.
155fn build_result(state: &State) -> RunResult {
156    let mut out = RunResult {
157        total_tokens: state.session.cumulative_token_usage.total_tokens,
158        ..RunResult::default()
159    };
160
161    for msg in state.session.messages() {
162        for action in &msg.actions {
163            if let crate::domain::ActionResult::Error { error } = &action.result {
164                out.errors
165                    .push(format!("{}: {}", action.action_type, error));
166            }
167        }
168    }
169
170    if let Some(last) = state
171        .session
172        .messages()
173        .iter()
174        .rev()
175        .find(|m| m.role == MessageRole::Assistant)
176    {
177        out.response = last.content.clone();
178        out.reasoning = last.thinking.clone();
179    }
180
181    out
182}
183
184/// Render a `RunResult` in the requested output format.
185pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
186    match format {
187        OutputFormat::Text => {
188            if result.response.is_empty() && !result.errors.is_empty() {
189                result.errors.join("\n")
190            } else {
191                result.response.clone()
192            }
193        },
194        OutputFormat::Markdown => {
195            let mut out = result.response.clone();
196            if !result.errors.is_empty() {
197                out.push_str("\n\n---\n\n## Errors\n\n");
198                for e in &result.errors {
199                    out.push_str(&format!("- {}\n", e));
200                }
201            }
202            out
203        },
204        OutputFormat::Json => {
205            let json = serde_json::json!({
206                "response": result.response,
207                "reasoning": result.reasoning,
208                "total_tokens": result.total_tokens,
209                "errors": result.errors,
210            });
211            serde_json::to_string_pretty(&json).unwrap_or_default()
212        },
213    }
214}