mermaid_cli/app/
run_non_interactive.rs1use 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#[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#[derive(Debug, Default, Clone)]
37pub struct RunOptions {
38 pub no_execute: bool,
42 pub task_id: Option<String>,
45}
46
47pub 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
58pub 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 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 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 let seed = Msg::SubmitPrompt {
104 text: prompt,
105 attachment_ids: vec![],
106 };
107 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
153fn 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
184pub 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}