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_with(
50 config: Config,
51 cwd: PathBuf,
52 model_id: String,
53 prompt: String,
54 opts: RunOptions,
55) -> Result<RunResult> {
56 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
57 let tools = if opts.no_execute {
60 std::sync::Arc::new(ToolRegistry::new())
61 } else {
62 ToolRegistry::build(
63 &config,
64 crate::providers::TuiMode::Headless,
65 providers.clone(),
66 )
67 };
68 let (mut runner, mut msg_rx) =
69 EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
70 runner = runner.without_terminal_title();
71
72 let mut state = State::new(config.clone(), cwd.clone(), model_id, chrono::Local::now());
73 let mut lifecycle = RuntimeLifecycle::new();
74
75 let (instructions, memory) =
82 crate::app::instructions::load_project_context(&cwd, &config.memory);
83 state.instructions = instructions;
84 state.memory = memory;
85
86 if !config.mcp_servers.is_empty() && !opts.no_execute {
92 runner.dispatch(crate::domain::Cmd::InitMcpServers(
93 config.mcp_servers.clone(),
94 ));
95 }
96
97 let seed = Msg::SubmitPrompt {
99 text: prompt,
100 attachment_ids: vec![],
101 };
102 state.now = chrono::Local::now();
104 let (new_state, cmds) = update(state, seed);
105 state = new_state;
106 for cmd in cmds {
107 runner.dispatch(cmd);
108 }
109
110 let deadline = Duration::from_secs(20 * 60);
111
112 let drive = async {
113 while !matches!(state.turn, TurnState::Idle) || !state.ui.queued_messages.is_empty() {
114 let msg = tokio::select! {
115 m = msg_rx.recv() => match m {
116 Some(m) => m,
117 None => break,
118 },
119 s = lifecycle.next_msg() => match s {
120 Some(s) => s,
121 None => continue,
122 },
123 };
124 state.now = chrono::Local::now();
125 let (new_state, cmds) = update(state, msg);
126 state = new_state;
127 for cmd in cmds {
128 runner.dispatch(cmd);
129 }
130 if state.should_exit {
131 break;
132 }
133 }
134 state
135 };
136
137 let final_state = timeout(deadline, drive).await.map_err(|_| {
138 anyhow::anyhow!(
139 "non-interactive run exceeded {} seconds",
140 deadline.as_secs()
141 )
142 })?;
143
144 runner.shutdown().await;
145 Ok(build_result(&final_state))
146}
147
148fn build_result(state: &State) -> RunResult {
151 let mut out = RunResult {
152 total_tokens: state.session.cumulative_token_usage.total_tokens,
153 ..RunResult::default()
154 };
155
156 for msg in state.session.messages() {
157 for action in &msg.actions {
158 if let crate::domain::ActionResult::Error { error } = &action.result {
159 out.errors
160 .push(format!("{}: {}", action.action_type, error));
161 }
162 }
163 }
164
165 if let Some(last) = state
166 .session
167 .messages()
168 .iter()
169 .rev()
170 .find(|m| m.role == MessageRole::Assistant)
171 {
172 out.response = last.content.clone();
173 out.reasoning = last.thinking.clone();
174 }
175
176 out
177}
178
179pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
181 match format {
182 OutputFormat::Text => {
183 if result.response.is_empty() && !result.errors.is_empty() {
184 result.errors.join("\n")
185 } else {
186 result.response.clone()
187 }
188 },
189 OutputFormat::Markdown => {
190 let mut out = result.response.clone();
191 if !result.errors.is_empty() {
192 out.push_str("\n\n---\n\n## Errors\n\n");
193 for e in &result.errors {
194 out.push_str(&format!("- {}\n", e));
195 }
196 }
197 out
198 },
199 OutputFormat::Json => {
200 let json = serde_json::json!({
201 "response": result.response,
202 "reasoning": result.reasoning,
203 "total_tokens": result.total_tokens,
204 "errors": result.errors,
205 });
206 serde_json::to_string_pretty(&json).unwrap_or_default()
207 },
208 }
209}