1use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13use tokio::time::timeout;
14
15use crate::app::lifecycle::RuntimeLifecycle;
16use crate::cli::OutputFormat;
17use crate::effect::EffectRunner;
18use crate::providers::ToolRegistry;
19use mermaid_domain::Config;
20use mermaid_domain::{Msg, RUN_EVENT_PROTOCOL_VERSION, RunEvent, State, TurnState, update};
21use mermaid_model::models::MessageRole;
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 pub session_id: String,
33 pub structured_output: Option<serde_json::Value>,
36}
37
38#[derive(Debug, Default, Clone)]
43pub struct RunOptions {
44 pub no_execute: bool,
48 pub task_id: Option<String>,
51 pub cancel: Option<tokio_util::sync::CancellationToken>,
57 pub deadline: Option<Duration>,
60 pub stream_ndjson: bool,
65 pub seed: Option<mermaid_domain::ConversationHistory>,
69 pub output_schema: Option<serde_json::Value>,
74 pub event_tx: Option<tokio::sync::broadcast::Sender<mermaid_domain::RunEvent>>,
78 pub plan: bool,
81 pub plan_autoaccept: bool,
84}
85
86#[expect(
97 clippy::too_many_lines,
98 reason = "predates the lint; see .github/baselines/expect_budget.txt"
99)]
100pub async fn run_non_interactive_with(
101 mut config: Config,
102 cwd: PathBuf,
103 model_id: String,
104 prompt: String,
105 opts: RunOptions,
106) -> Result<RunResult> {
107 if opts.plan_autoaccept {
111 config.plan.auto_approve = true;
112 config.plan.post_approve = Some(mermaid_domain::PlanPostApprove::Start);
113 }
114
115 let plugin_assets = crate::app::plugin_assets::load();
119 for warning in crate::app::plugin_assets::apply(&mut config, &plugin_assets) {
120 eprintln!("mermaid: {warning}");
121 }
122 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
123 let tools = if opts.no_execute {
126 std::sync::Arc::new(ToolRegistry::new())
127 } else {
128 ToolRegistry::build(
129 &config,
130 crate::providers::TuiMode::Headless,
131 providers.clone(),
132 )
133 };
134 let (mut runner, mut msg_rx) =
135 EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
136 runner = runner.without_terminal_title();
137
138 let stream_ndjson = opts.stream_ndjson;
140 let event_model = model_id.clone();
141
142 let mut state = State::new(
143 config.clone(),
144 cwd.clone(),
145 model_id,
146 chrono::Local::now(),
147 std::env::temp_dir(),
148 );
149 if let Some(history) = opts.seed.clone() {
155 state.seed_conversation(history);
156 }
157 state
158 .ui
159 .pending_msgs
160 .push_back(Msg::SessionProvenanceResolved(
161 crate::session::probe_session_provenance(&cwd),
162 ));
163 let session_id = state.session.conversation.id.clone();
164 let mut lifecycle = RuntimeLifecycle::new();
165
166 let (instructions, memory, skills) =
173 crate::app::instructions::load_project_context(&cwd, &config.memory);
174 state.instructions = instructions;
175 state.memory = memory;
176 state.skills = skills;
177 state.plugin_commands = plugin_assets.commands;
178
179 if !config.mcp_servers.is_empty() && !opts.no_execute {
185 runner.dispatch(mermaid_domain::Cmd::InitMcpServers(
186 config.mcp_servers.clone(),
187 ));
188 }
189
190 if !state.session.conversation.tasks.tasks.is_empty() {
193 runner.dispatch(mermaid_domain::Cmd::SyncTaskStore(
194 state.session.conversation.tasks.clone(),
195 ));
196 }
197
198 match crate::session::scratchpad::ensure(&cwd, &session_id) {
208 Ok(path) => state.session.scratchpad = Some(path),
209 Err(err) => tracing::warn!(%err, "scratchpad unavailable for this run"),
210 }
211
212 let started = RunEvent::SessionStarted {
215 protocol_version: RUN_EVENT_PROTOCOL_VERSION,
216 cli_version: env!("CARGO_PKG_VERSION").to_string(),
217 model: event_model,
218 task_id: opts.task_id.clone(),
219 session_id: session_id.clone(),
220 };
221 if stream_ndjson {
222 emit_run_event(&started);
223 }
224 if let Some(tx) = &opts.event_tx {
225 let _ = tx.send(started);
226 }
227
228 if opts.plan {
232 state.now = chrono::Local::now();
233 let (new_state, cmds) = update(state, Msg::Slash(mermaid_domain::SlashCmd::Plan(None)));
234 state = new_state;
235 for cmd in cmds {
236 runner.dispatch(cmd);
237 }
238 }
239
240 let seed = Msg::SubmitPrompt {
242 text: prompt,
243 attachment_ids: vec![],
244 };
245 state.now = chrono::Local::now();
247 let (new_state, cmds) = update(state, seed);
248 state = new_state;
249 for cmd in cmds {
250 runner.dispatch(cmd);
251 }
252
253 let deadline = opts.deadline.unwrap_or(Duration::from_secs(20 * 60));
254 let cancel = opts.cancel.clone();
255
256 let final_state = timeout(
257 deadline,
258 drive_to_idle(
259 state,
260 &mut runner,
261 &mut msg_rx,
262 &mut lifecycle,
263 cancel.as_ref(),
264 stream_ndjson,
265 opts.event_tx.as_ref(),
266 ),
267 )
268 .await
269 .map_err(|_| {
270 anyhow::anyhow!(
271 "non-interactive run exceeded {} seconds",
272 deadline.as_secs()
273 )
274 })?;
275
276 let mut result = build_result(&final_state);
277
278 if let Some(schema) = opts.output_schema.clone() {
280 let cancelled = cancel.as_ref().is_some_and(|t| t.is_cancelled());
281 if cancelled || final_state.should_exit || result.response.is_empty() {
282 result
283 .errors
284 .push("output_schema: skipped (run ended without a final answer)".to_string());
285 } else {
286 let final_state = timeout(
287 deadline,
288 run_formatting_turn(
289 final_state,
290 schema.clone(),
291 &mut runner,
292 &mut msg_rx,
293 &mut lifecycle,
294 cancel.as_ref(),
295 stream_ndjson,
296 opts.event_tx.as_ref(),
297 ),
298 )
299 .await
300 .map_err(|_| {
301 anyhow::anyhow!(
302 "output-schema formatting turn exceeded {} seconds",
303 deadline.as_secs()
304 )
305 })?;
306 apply_schema_outcome(&mut result, &final_state, &schema);
307 }
308 }
309
310 runner.shutdown().await;
311 let terminal = RunEvent::Result {
315 response: result.response.clone(),
316 reasoning: result.reasoning.clone(),
317 total_tokens: result.total_tokens as u64,
318 errors: result.errors.clone(),
319 session_id: result.session_id.clone(),
320 structured_output: result.structured_output.clone(),
321 };
322 if stream_ndjson {
323 emit_run_event(&terminal);
324 }
325 if let Some(tx) = &opts.event_tx {
326 let _ = tx.send(terminal);
327 }
328 Ok(result)
329}
330
331async fn drive_to_idle(
335 mut state: State,
336 runner: &mut EffectRunner,
337 msg_rx: &mut tokio::sync::mpsc::Receiver<Msg>,
338 lifecycle: &mut RuntimeLifecycle,
339 cancel: Option<&tokio_util::sync::CancellationToken>,
340 stream_ndjson: bool,
341 event_tx: Option<&tokio::sync::broadcast::Sender<RunEvent>>,
342) -> State {
343 const CANCEL_GRACE: Duration = Duration::from_secs(15);
346 let mut cancel_deadline: Option<tokio::time::Instant> = None;
350 loop {
351 let idle = matches!(state.turn, TurnState::Idle);
352 if drive_should_stop(
353 idle,
354 state.ui.queued_messages.is_empty(),
355 cancel_deadline.is_some(),
356 ) {
357 break;
358 }
359 let msg = tokio::select! {
360 m = msg_rx.recv() => match m {
361 Some(m) => m,
362 None => break,
363 },
364 s = lifecycle.next_msg() => match s {
365 Some(s) => s,
366 None => continue,
367 },
368 _ = async {
369 match &cancel {
370 Some(token) => token.cancelled().await,
371 None => std::future::pending().await,
372 }
373 }, if cancel.is_some() && cancel_deadline.is_none() => {
374 cancel_deadline = Some(tokio::time::Instant::now() + CANCEL_GRACE);
375 Msg::CancelTurn
376 },
377 _ = tokio::time::sleep_until(
380 cancel_deadline
381 .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86_400)),
382 ), if cancel_deadline.is_some() => {
383 tracing::warn!("cancelled run did not unwind within grace; hard-stopping");
384 break;
385 },
386 };
387 if let Msg::TransientStatus { text } = &msg {
392 eprintln!("{text}");
393 }
394 if (stream_ndjson || event_tx.is_some())
398 && let Some(event) = RunEvent::from_msg(&msg)
399 {
400 if stream_ndjson {
401 emit_run_event(&event);
402 }
403 if let Some(tx) = event_tx {
404 let _ = tx.send(event);
405 }
406 }
407 state.now = chrono::Local::now();
408 let (new_state, cmds) = update(state, msg);
409 state = new_state;
410 for cmd in cmds {
411 runner.dispatch(cmd);
412 }
413 if state.should_exit {
414 break;
415 }
416 }
417 state
418}
419
420const FORMAT_PROMPT: &str = "Convert your final answer into a single JSON object that \
422conforms to the provided schema. Respond with only the JSON object - no prose, no code fences.";
423
424#[expect(clippy::too_many_arguments)]
428async fn run_formatting_turn(
429 mut state: State,
430 schema: serde_json::Value,
431 runner: &mut EffectRunner,
432 msg_rx: &mut tokio::sync::mpsc::Receiver<Msg>,
433 lifecycle: &mut RuntimeLifecycle,
434 cancel: Option<&tokio_util::sync::CancellationToken>,
435 stream_ndjson: bool,
436 event_tx: Option<&tokio::sync::broadcast::Sender<RunEvent>>,
437) -> State {
438 state.output_schema = Some(schema);
439 state.now = chrono::Local::now();
440 let (new_state, cmds) = update(
441 state,
442 Msg::SubmitPrompt {
443 text: FORMAT_PROMPT.to_string(),
444 attachment_ids: vec![],
445 },
446 );
447 state = new_state;
448 for cmd in cmds {
449 runner.dispatch(cmd);
450 }
451 drive_to_idle(
452 state,
453 runner,
454 msg_rx,
455 lifecycle,
456 cancel,
457 stream_ndjson,
458 event_tx,
459 )
460 .await
461}
462
463fn apply_schema_outcome(result: &mut RunResult, state: &State, schema: &serde_json::Value) {
469 let formatted = build_result(state);
471 result.total_tokens = formatted.total_tokens;
472 if formatted.response.is_empty() || formatted.response == result.response {
473 result
474 .errors
475 .push("output_schema: formatting turn produced no output".to_string());
476 return;
477 }
478 let text = strip_code_fences(&formatted.response);
479 result.response = text.to_string();
480 let parsed: serde_json::Value = match serde_json::from_str(text) {
481 Ok(v) => v,
482 Err(e) => {
483 result
484 .errors
485 .push(format!("output_schema: response is not valid JSON: {e}"));
486 return;
487 },
488 };
489 let validator = match jsonschema::validator_for(schema) {
490 Ok(v) => v,
491 Err(e) => {
492 result
493 .errors
494 .push(format!("output_schema: schema did not compile: {e}"));
495 return;
496 },
497 };
498 if let Some(err) = validator.iter_errors(&parsed).next() {
499 result
500 .errors
501 .push(format!("output_schema: response does not conform: {err}"));
502 return;
503 }
504 result.structured_output = Some(parsed);
505}
506
507fn strip_code_fences(text: &str) -> &str {
510 let t = text.trim();
511 let Some(rest) = t.strip_prefix("```") else {
512 return t;
513 };
514 let Some(rest) = rest.split_once('\n').map(|(_, r)| r) else {
515 return t;
516 };
517 match rest.strip_suffix("```") {
518 Some(inner) => inner.trim(),
519 None => t,
520 }
521}
522
523fn emit_run_event(event: &RunEvent) {
525 println!("{}", serde_json::to_string(event).unwrap_or_default());
526}
527
528fn build_result(state: &State) -> RunResult {
531 let mut out = RunResult {
532 total_tokens: state.session.cumulative_token_usage.total_tokens(),
533 session_id: state.session.conversation.id.clone(),
534 ..RunResult::default()
535 };
536
537 for msg in state.session.messages() {
538 for action in &msg.actions {
539 if let mermaid_domain::ActionResult::Error { error } = &action.result {
540 out.errors
541 .push(format!("{}: {}", action.action_type, error));
542 }
543 }
544 }
545
546 let messages = state.session.messages();
553 if let Some(last_idx) = messages
554 .iter()
555 .rposition(|m| m.role == MessageRole::Assistant)
556 {
557 let mut head_idx = last_idx;
558 while head_idx > 0
559 && messages[head_idx].kind == mermaid_model::models::ChatMessageKind::Continuation
560 && let Some(prev_idx) = messages[..head_idx]
561 .iter()
562 .rposition(|m| m.role == MessageRole::Assistant)
563 && matches!(
566 messages[prev_idx].kind,
567 mermaid_model::models::ChatMessageKind::Normal
568 | mermaid_model::models::ChatMessageKind::Continuation
569 )
570 && messages[prev_idx].tool_calls.is_none()
571 {
572 head_idx = prev_idx;
573 }
574 let mut response = String::new();
575 let mut reasoning: Option<String> = None;
576 for msg in messages[head_idx..=last_idx]
577 .iter()
578 .filter(|m| m.role == MessageRole::Assistant)
579 {
580 let skip = mermaid_model::utils::continuation_overlap(&response, &msg.content);
581 response.push_str(&msg.content[skip..]);
582 if let Some(t) = &msg.thinking {
583 match &mut reasoning {
584 Some(r) => {
585 r.push_str("\n\n");
586 r.push_str(t);
587 },
588 None => reasoning = Some(t.clone()),
589 }
590 }
591 }
592 out.response = response;
593 out.reasoning = reasoning;
594 }
595
596 out
597}
598
599#[must_use]
601pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
602 match format {
603 OutputFormat::Text => {
604 if result.response.is_empty() && !result.errors.is_empty() {
605 result.errors.join("\n")
606 } else {
607 result.response.clone()
608 }
609 },
610 OutputFormat::Markdown => {
611 let mut out = result.response.clone();
612 if !result.errors.is_empty() {
613 out.push_str("\n\n---\n\n## Errors\n\n");
614 for e in &result.errors {
615 out.push_str(&format!("- {e}\n"));
616 }
617 }
618 out
619 },
620 OutputFormat::Json => {
621 let event = RunEvent::Result {
624 response: result.response.clone(),
625 reasoning: result.reasoning.clone(),
626 total_tokens: result.total_tokens as u64,
627 errors: result.errors.clone(),
628 session_id: result.session_id.clone(),
629 structured_output: result.structured_output.clone(),
630 };
631 serde_json::to_string_pretty(&event).unwrap_or_default()
632 },
633 OutputFormat::Ndjson => {
634 String::new()
636 },
637 }
638}
639
640fn drive_should_stop(idle: bool, queue_empty: bool, cancelling: bool) -> bool {
647 idle && (queue_empty || cancelling)
648}
649
650#[cfg(test)]
651mod tests {
652 use super::{build_result, drive_should_stop};
653
654 #[test]
655 fn build_result_joins_an_auto_continued_reply() {
656 use mermaid_model::models::{ChatMessage, ChatMessageKind};
657 let mut state = mermaid_domain::State::new(
658 mermaid_domain::Config::default(),
659 std::path::PathBuf::from("/tmp/p"),
660 "ollama/test".to_string(),
661 chrono::Local::now(),
662 std::path::PathBuf::from("/tmp"),
663 );
664 state
665 .session
666 .append(ChatMessage::user("audit the widget"), state.now);
667 state.session.append(
668 ChatMessage::assistant("part one covers the resolver internals"),
669 state.now,
670 );
671 let mut cont = ChatMessage::assistant("the resolver internals, part two the adapters.");
674 cont.kind = ChatMessageKind::Continuation;
675 state.session.append(cont, state.now);
676
677 let result = build_result(&state);
678 assert_eq!(
679 result.response, "part one covers the resolver internals, part two the adapters.",
680 "headless output joins the whole chain, echo trimmed"
681 );
682 }
683
684 #[test]
685 fn build_result_without_chain_takes_the_last_reply() {
686 use mermaid_model::models::ChatMessage;
687 let mut state = mermaid_domain::State::new(
688 mermaid_domain::Config::default(),
689 std::path::PathBuf::from("/tmp/p"),
690 "ollama/test".to_string(),
691 chrono::Local::now(),
692 std::path::PathBuf::from("/tmp"),
693 );
694 state.session.append(ChatMessage::user("first"), state.now);
695 state
696 .session
697 .append(ChatMessage::assistant("earlier reply"), state.now);
698 state.session.append(ChatMessage::user("second"), state.now);
699 state
700 .session
701 .append(ChatMessage::assistant("final reply"), state.now);
702 let result = build_result(&state);
703 assert_eq!(result.response, "final reply");
704 }
705
706 #[test]
707 fn strip_code_fences_unwraps_single_fence_only() {
708 use super::strip_code_fences;
709 assert_eq!(strip_code_fences("{\"a\":1}"), "{\"a\":1}");
710 assert_eq!(strip_code_fences("```json\n{\"a\":1}\n```"), "{\"a\":1}");
711 assert_eq!(strip_code_fences("```\n{\"a\":1}\n```"), "{\"a\":1}");
712 assert_eq!(
714 strip_code_fences("```json\n{\"a\":1}"),
715 "```json\n{\"a\":1}"
716 );
717 assert_eq!(strip_code_fences(" {\"a\":1} "), "{\"a\":1}");
718 }
719
720 fn schema_state(reply: &str) -> mermaid_domain::State {
721 use mermaid_model::models::ChatMessage;
722 let mut state = mermaid_domain::State::new(
723 mermaid_domain::Config::default(),
724 std::path::PathBuf::from("/tmp/p"),
725 "ollama/test".to_string(),
726 chrono::Local::now(),
727 std::path::PathBuf::from("/tmp"),
728 );
729 state.session.append(ChatMessage::user("q"), state.now);
730 state
731 .session
732 .append(ChatMessage::assistant("the plain answer"), state.now);
733 state
734 .session
735 .append(ChatMessage::user("format it"), state.now);
736 state
737 .session
738 .append(ChatMessage::assistant(reply), state.now);
739 state
740 }
741
742 fn base_result() -> super::RunResult {
743 super::RunResult {
744 response: "the plain answer".to_string(),
745 ..super::RunResult::default()
746 }
747 }
748
749 #[test]
750 fn schema_outcome_valid_json_sets_structured_output() {
751 let schema = serde_json::json!({
752 "type": "object",
753 "properties": {"answer": {"type": "integer"}},
754 "required": ["answer"],
755 });
756 let state = schema_state("```json\n{\"answer\": 42}\n```");
757 let mut result = base_result();
758 super::apply_schema_outcome(&mut result, &state, &schema);
759 assert_eq!(result.response, "{\"answer\": 42}");
760 assert_eq!(
761 result.structured_output,
762 Some(serde_json::json!({"answer": 42}))
763 );
764 assert!(result.errors.is_empty(), "{:?}", result.errors);
765 }
766
767 #[test]
768 fn schema_outcome_invalid_json_keeps_text_and_records() {
769 let schema = serde_json::json!({"type": "object"});
770 let state = schema_state("not json at all");
771 let mut result = base_result();
772 super::apply_schema_outcome(&mut result, &state, &schema);
773 assert_eq!(result.response, "not json at all");
774 assert!(result.structured_output.is_none());
775 assert!(
776 result.errors.iter().any(|e| e.contains("not valid JSON")),
777 "{:?}",
778 result.errors
779 );
780 }
781
782 #[test]
783 fn schema_outcome_nonconforming_json_records_reason() {
784 let schema = serde_json::json!({
785 "type": "object",
786 "properties": {"answer": {"type": "integer"}},
787 "required": ["answer"],
788 });
789 let state = schema_state("{\"wrong\": true}");
790 let mut result = base_result();
791 super::apply_schema_outcome(&mut result, &state, &schema);
792 assert!(result.structured_output.is_none());
793 assert!(
794 result.errors.iter().any(|e| e.contains("does not conform")),
795 "{:?}",
796 result.errors
797 );
798 }
799
800 #[test]
801 fn schema_outcome_no_new_reply_keeps_original() {
802 let schema = serde_json::json!({"type": "object"});
805 use mermaid_model::models::ChatMessage;
806 let mut state = mermaid_domain::State::new(
807 mermaid_domain::Config::default(),
808 std::path::PathBuf::from("/tmp/p"),
809 "ollama/test".to_string(),
810 chrono::Local::now(),
811 std::path::PathBuf::from("/tmp"),
812 );
813 state.session.append(ChatMessage::user("q"), state.now);
814 state
815 .session
816 .append(ChatMessage::assistant("the plain answer"), state.now);
817 let mut result = base_result();
818 super::apply_schema_outcome(&mut result, &state, &schema);
819 assert_eq!(result.response, "the plain answer");
820 assert!(result.structured_output.is_none());
821 assert!(
822 result
823 .errors
824 .iter()
825 .any(|e| e.contains("produced no output")),
826 "{:?}",
827 result.errors
828 );
829 }
830
831 #[test]
832 fn result_event_carries_structured_output_to_subscribers() {
833 let event = mermaid_domain::RunEvent::Result {
837 response: "done".to_string(),
838 reasoning: None,
839 total_tokens: 3,
840 errors: vec![],
841 session_id: "s".to_string(),
842 structured_output: None,
843 };
844 let wire = serde_json::to_string(&event).unwrap();
845 assert!(wire.contains("\"type\":\"result\""), "{wire}");
846 let (tx, mut rx) = tokio::sync::broadcast::channel::<mermaid_domain::RunEvent>(4);
847 tx.send(event.clone()).unwrap();
848 assert_eq!(rx.try_recv().unwrap(), event);
849 }
850
851 #[test]
852 fn drive_keeps_running_until_idle() {
853 assert!(!drive_should_stop(false, true, false));
855 assert!(!drive_should_stop(false, true, true));
856 assert!(!drive_should_stop(false, false, true));
857 }
858
859 #[test]
860 fn drive_stops_when_idle_and_drained() {
861 assert!(drive_should_stop(true, true, false));
863 }
864
865 #[test]
866 fn drive_keeps_draining_queue_when_not_cancelling() {
867 assert!(!drive_should_stop(true, false, false));
870 }
871
872 #[test]
873 fn cancel_stops_at_idle_even_with_queued_messages() {
874 assert!(drive_should_stop(true, false, true));
877 }
878}