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;
13
14use crate::app::lifecycle::RuntimeLifecycle;
15use crate::cli::OutputFormat;
16use crate::effect::EffectRunner;
17use crate::engine::{
18    DriveExit, DrivePolicy, Engine, EngineHandle, Inbox, Observation, OnCancel, StepObserver,
19    StopWhen,
20};
21use crate::providers::ToolRegistry;
22use mermaid_domain::Config;
23use mermaid_domain::{Msg, RUN_EVENT_PROTOCOL_VERSION, RunEvent, State};
24use mermaid_model::models::MessageRole;
25
26/// Output shape the CLI prints.
27#[derive(Debug, Default)]
28pub struct RunResult {
29    pub response: String,
30    pub reasoning: Option<String>,
31    pub total_tokens: usize,
32    pub errors: Vec<String>,
33    /// Conversation/session id that owns this run — resumable with
34    /// `mermaid run --resume <id>`.
35    pub session_id: String,
36    /// `--output-schema` runs: the response parsed as JSON, present only when
37    /// it parsed AND validated against the schema.
38    pub structured_output: Option<serde_json::Value>,
39}
40
41/// Per-invocation options for `run_non_interactive`.
42///
43/// Added as a struct so new flags can land without reshuffling the
44/// function's positional args. All fields default to "no change".
45#[derive(Debug, Default, Clone)]
46pub struct RunOptions {
47    /// When true, register an empty `ToolRegistry` — the model sees no
48    /// tools and can't take actions. Dry-run mode for
49    /// `mermaid run --no-execute`.
50    pub no_execute: bool,
51    /// Durable runtime task that owns this run, when launched through
52    /// `mermaidd` or `mermaid run` task creation.
53    pub task_id: Option<String>,
54    /// External cancellation. When it fires, the driver injects
55    /// `Msg::CancelTurn` — the same message the TUI's Esc sends — so the
56    /// reducer unwinds the turn gracefully (tool process tree killed, turn
57    /// `JoinSet` drained). If the reducer hasn't reached `Idle` within a grace
58    /// window after that, the drive loop hard-stops.
59    pub cancel: Option<tokio_util::sync::CancellationToken>,
60    /// Wall-clock budget override. `None` keeps the built-in 20-minute
61    /// deadline.
62    pub deadline: Option<Duration>,
63    /// When true, the driver streams the run lifecycle to stdout as
64    /// newline-delimited `RunEvent` JSON (`mermaid run --format ndjson`). Off
65    /// for the daemon scheduler and every other caller, which own their own
66    /// output.
67    pub stream_ndjson: bool,
68    /// Saved conversation to seed the session with (`--resume <id>` /
69    /// `--continue`). The run appends to the SAME session id, so repeated
70    /// `--resume <id>` invocations chain naturally.
71    pub seed: Option<mermaid_domain::ConversationHistory>,
72    /// `--output-schema`: JSON Schema the final answer must conform to. The
73    /// agentic loop runs normally; one extra FORMATTING turn (no tools,
74    /// native constrained output where supported) reshapes the final answer,
75    /// validated client-side. See `run_formatting_turn`.
76    pub output_schema: Option<serde_json::Value>,
77    /// Live `RunEvent` tee for daemon task subscriptions (`subscribe_task`).
78    /// Every event that would print on an NDJSON stream is also broadcast
79    /// here (send is sync + non-blocking; no-receiver errors are ignored).
80    pub event_tx: Option<tokio::sync::broadcast::Sender<mermaid_domain::RunEvent>>,
81    /// Where to publish this run's [`EngineHandle`], once there is one.
82    ///
83    /// The handle is the run's mailbox: it is what lets the daemon put a
84    /// message into a task that is already happening, rather than only watching
85    /// it and killing it. Sent exactly once, as soon as the engine exists —
86    /// which is before the first model call, so a caller that awaits it can
87    /// reach the whole run.
88    ///
89    /// An `mpsc` rather than a `oneshot` only because `RunOptions` is `Clone`
90    /// and a `oneshot::Sender` is not.
91    pub handle_tx: Option<tokio::sync::mpsc::Sender<EngineHandle<RunEvent>>>,
92    /// `mermaid run --plan`: enter plan mode before the prompt seeds, so the
93    /// run explores read-only and delivers a plan file.
94    pub plan: bool,
95    /// `--plan-autoaccept`: the headless approval starts implementation
96    /// immediately instead of ending the run at the plan.
97    pub plan_autoaccept: bool,
98}
99
100/// Drive one prompt to completion with explicit per-call options. Bounded by a
101/// generous 20-minute wall-clock so a runaway model doesn't hang a script.
102///
103/// # Errors
104///
105/// Resolving the provider for `model_id`, the run exceeding the 20-minute
106/// wall-clock, and a failure in the driving loop. A model that answers with a
107/// refusal, a tool that fails, and a turn that ends without finishing the task
108/// are all `Ok` — that is what the returned [`RunResult`] describes, and the
109/// caller turns it into an exit code.
110#[expect(
111    clippy::too_many_lines,
112    reason = "predates the lint; see .github/baselines/expect_budget.txt"
113)]
114pub async fn run_non_interactive_with(
115    mut config: Config,
116    cwd: PathBuf,
117    model_id: String,
118    prompt: String,
119    opts: RunOptions,
120) -> Result<RunResult> {
121    // `--plan-autoaccept`: the headless exit_plan_mode path consults these —
122    // auto-approve with post_approve=start flows the run straight from the
123    // approved plan into implementation.
124    if opts.plan_autoaccept {
125        config.plan.auto_approve = true;
126        config.plan.post_approve = Some(mermaid_domain::PlanPostApprove::Start);
127    }
128
129    // Fold enabled plugins' MCP servers + agent types into the merged
130    // config before anything consumes it (same policy as the interactive
131    // path; warnings go to stderr — there is no transcript here yet).
132    let plugin_assets = crate::app::plugin_assets::load();
133    for warning in crate::app::plugin_assets::apply(&mut config, &plugin_assets) {
134        eprintln!("mermaid: {warning}");
135    }
136    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
137    // F6 `--no-execute`: build an empty tool registry so the model can
138    // plan but never act. MCP init below is also skipped to match.
139    let tools = if opts.no_execute {
140        std::sync::Arc::new(ToolRegistry::new())
141    } else {
142        ToolRegistry::build(
143            &config,
144            crate::providers::TuiMode::Headless,
145            providers.clone(),
146        )
147    };
148    let (mut runner, mut msg_rx) =
149        EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
150    runner = runner.without_terminal_title();
151
152    // Captured before `model_id` is moved into `State`, for the NDJSON stream.
153    let stream_ndjson = opts.stream_ndjson;
154    let event_model = model_id.clone();
155
156    // One event bus for the whole run. The caller's when it supplied one — the
157    // daemon subscribes before the run starts, to catch the line that names the
158    // session — and otherwise one of ours, but only when a handle was asked
159    // for: an unwatched run must not pay to project events nobody reads. The
160    // handle and the observer share it, or `subscribe()` would be silent.
161    let event_tx = match (opts.event_tx.clone(), opts.handle_tx.is_some()) {
162        (Some(tx), _) => Some(tx),
163        (None, true) => Some(tokio::sync::broadcast::channel(RUN_EVENT_BUS_CAPACITY).0),
164        (None, false) => None,
165    };
166
167    let mut state = State::new(
168        config.clone(),
169        cwd.clone(),
170        model_id,
171        chrono::Local::now(),
172        std::env::temp_dir(),
173    );
174    // `--resume <id>` / `--continue`: seed the session from the saved
175    // conversation (same machinery as the interactive path — meters restored,
176    // orphan tool pairs repaired via normalize_history), then backfill
177    // provenance blanks. The seeded id survives, so the run appends to the
178    // same `.mermaid/conversations/<id>.json`.
179    if let Some(history) = opts.seed.clone() {
180        state.seed_conversation(history);
181    }
182    state
183        .ui
184        .pending_msgs
185        .push_back(Msg::SessionProvenanceResolved(
186            crate::session::probe_session_provenance(&cwd),
187        ));
188    let session_id = state.session.conversation.id.clone();
189    let mut lifecycle = RuntimeLifecycle::new();
190
191    // Load project instructions + the memory index synchronously. The
192    // interactive TUI gets these from the config watcher's first poll
193    // (`run.rs`), which the headless driver never spawns — so without this the
194    // model call would go out with no MERMAID.md/AGENTS.md and no memory, while
195    // `mermaid doctor` reports them loaded. `build_chat_request` reads them off
196    // `state`, so they must be in place before the seed below.
197    let (instructions, memory, skills) =
198        crate::app::instructions::load_project_context(&cwd, &config.memory);
199    state.instructions = instructions;
200    state.memory = memory;
201    state.skills = skills;
202    state.plugin_commands = plugin_assets.commands;
203
204    // Bootstrap effects (MCP init) before the first prompt.
205    //
206    // Skip MCP init when `--no-execute` — MCP tools would advertise
207    // through the registry we just emptied, so spinning up their
208    // processes is wasted work.
209    if !config.mcp_servers.is_empty() && !opts.no_execute {
210        runner.dispatch(mermaid_domain::Cmd::InitMcpServers(
211            config.mcp_servers.clone(),
212        ));
213    }
214
215    // A resumed session may carry an in-flight checklist; hand it to the
216    // TaskBroker so headless task tool calls continue the restored list.
217    if !state.session.conversation.tasks.tasks.is_empty() {
218        runner.dispatch(mermaid_domain::Cmd::SyncTaskStore(
219            state.session.conversation.tasks.clone(),
220        ));
221    }
222
223    // Materialize the per-session scratch dir SYNCHRONOUSLY and stamp it
224    // before the seed. The interactive path goes through
225    // `Cmd::EnsureScratchpad` -> `Msg::ScratchpadReady`, but here the first
226    // (often only) request is built immediately below — the async round
227    // trip loses that race and the prompt ships without the scratchpad
228    // path, so the model can't address it until a tool turn has elapsed.
229    // Same rationale as the synchronous project-context load above.
230    // `session_id` was captured after any seed, so a resumed run adopts
231    // the dir keyed by its restored conversation id.
232    match crate::session::scratchpad::ensure(&cwd, &session_id) {
233        Ok(path) => state.session.scratchpad = Some(path),
234        Err(err) => tracing::warn!(%err, "scratchpad unavailable for this run"),
235    }
236
237    // First line of the NDJSON stream: protocol + run identity. Broadcast
238    // to any daemon subscriber regardless of stdout streaming.
239    let started = RunEvent::SessionStarted {
240        protocol_version: RUN_EVENT_PROTOCOL_VERSION,
241        cli_version: env!("CARGO_PKG_VERSION").to_string(),
242        model: event_model,
243        task_id: opts.task_id.clone(),
244        session_id: session_id.clone(),
245    };
246    if stream_ndjson {
247        emit_run_event(&started);
248    }
249    if let Some(tx) = &event_tx {
250        let _ = tx.send(started);
251    }
252
253    // Publish this run's mailbox before the engine takes the runner, so a
254    // caller awaiting the handle can reach the run from its first model call
255    // onward. The bus is the caller's own when it supplied one — the daemon
256    // subscribes before the run starts, to catch the line that names the
257    // session — and otherwise one of ours, which keeps `subscribe()` total.
258    if let Some((want, events)) = opts.handle_tx.as_ref().zip(event_tx.clone()) {
259        let _ = want.try_send(EngineHandle::new(runner.sender(), events));
260    }
261
262    // Everything the reducer needs is in place: hand the state and the runner
263    // to the engine, which owns the drive from here.
264    let mut engine = Engine::new(state, runner).with_observer(RunStream {
265        stream_ndjson,
266        event_tx: event_tx.clone(),
267    });
268
269    // `--plan`: flip into plan mode BEFORE the prompt seeds, through the
270    // same reducer path as the interactive `/plan` (path allocation, model
271    // swap, prompt injection all included).
272    //
273    // Both this and the seed below go through `reduce`, not `step`: they are
274    // synthetic inputs the driver manufactures, and projecting them onto the
275    // public stream would announce events no client sent.
276    if opts.plan {
277        engine.reduce(
278            chrono::Local::now(),
279            Msg::Slash(mermaid_domain::SlashCmd::Plan(None)),
280        );
281    }
282
283    // Seed the turn. The clock is injected as data so the reducer stays pure.
284    engine.reduce(
285        chrono::Local::now(),
286        Msg::SubmitPrompt {
287            text: prompt,
288            attachment_ids: vec![],
289        },
290    );
291
292    let deadline = opts.deadline.unwrap_or(Duration::from_secs(20 * 60));
293    let cancel = opts.cancel.clone();
294    let policy = DrivePolicy {
295        stop: StopWhen::Settled,
296        cancel: cancel.clone(),
297        // Cancellation unwinds the turn through the reducer — the same
298        // `Msg::CancelTurn` the TUI's Esc sends — rather than dropping it.
299        on_cancel: OnCancel::Unwind {
300            grace: CANCEL_GRACE,
301        },
302        deadline: Some(deadline),
303    };
304    let mut inbox = Inbox::new(&mut msg_rx).with_lifecycle(&mut lifecycle);
305
306    if engine.drive(&mut inbox, &policy).await == DriveExit::TimedOut {
307        let (_, runner, _) = engine.into_parts();
308        return timed_out(runner, "non-interactive run", deadline).await;
309    }
310
311    let mut result = build_result(engine.state());
312
313    // `--output-schema`: one extra formatting turn on the completed run.
314    if let Some(schema) = opts.output_schema.clone() {
315        let cancelled = cancel.as_ref().is_some_and(|t| t.is_cancelled());
316        if cancelled || engine.state().should_exit || result.response.is_empty() {
317            result
318                .errors
319                .push("output_schema: skipped (run ended without a final answer)".to_string());
320        } else {
321            let exit = run_formatting_turn(&mut engine, schema.clone(), &mut inbox, &policy).await;
322            if exit == DriveExit::TimedOut {
323                let (_, runner, _) = engine.into_parts();
324                return timed_out(runner, "output-schema formatting turn", deadline).await;
325            }
326            apply_schema_outcome(&mut result, engine.state(), &schema);
327        }
328    }
329
330    let (_, runner, _) = engine.into_parts();
331    runner.shutdown().await;
332    // Terminal line of the stream: the aggregated result — sent to daemon
333    // subscribers even when stdout NDJSON is off (that's how a
334    // `subscribe_task` stream knows to close).
335    let terminal = RunEvent::Result {
336        response: result.response.clone(),
337        reasoning: result.reasoning.clone(),
338        total_tokens: result.total_tokens as u64,
339        errors: result.errors.clone(),
340        session_id: result.session_id.clone(),
341        structured_output: result.structured_output.clone(),
342    };
343    if stream_ndjson {
344        emit_run_event(&terminal);
345    }
346    if let Some(tx) = &event_tx {
347        let _ = tx.send(terminal);
348    }
349    Ok(result)
350}
351
352/// Slots on the run's event bus. Matches the daemon's own: a lagged
353/// subscriber gets a `RecvError::Lagged` marker rather than backpressure into
354/// the run, which must never stall on someone watching it.
355const RUN_EVENT_BUS_CAPACITY: usize = 1024;
356
357/// How long a cancelled run may keep unwinding before the drive hard-stops.
358/// Generous next to the turn scope's own ~2s teardown bound.
359const CANCEL_GRACE: Duration = Duration::from_secs(15);
360
361/// Everything a headless run says out loud, as an engine observer.
362///
363/// Runs before `update` consumes the message, so the projection sees fields the
364/// reducer strips, and each message is projected ONCE — printed and/or
365/// broadcast. Most messages have no projection at all.
366struct RunStream {
367    stream_ndjson: bool,
368    event_tx: Option<tokio::sync::broadcast::Sender<RunEvent>>,
369}
370
371impl StepObserver for RunStream {
372    async fn observe(&mut self, obs: Observation<'_>) {
373        // Plumbing notices ("Starting the local Ollama server…") have no
374        // renderer here — mirror them to stderr live so the user isn't staring
375        // at silence during an up-to-15s server start. stderr, not stdout: the
376        // response payload must stay clean for scripts.
377        if let Msg::TransientStatus { text } = obs.msg {
378            eprintln!("{text}");
379        }
380        if !self.stream_ndjson && self.event_tx.is_none() {
381            return;
382        }
383        let Some(event) = RunEvent::from_msg(obs.msg) else {
384            return;
385        };
386        if self.stream_ndjson {
387            emit_run_event(&event);
388        }
389        if let Some(tx) = &self.event_tx {
390            let _ = tx.send(event);
391        }
392    }
393}
394
395/// Shut the runner down, then report the timeout.
396///
397/// The deadline is a drive-policy arm rather than a `timeout()` wrapper
398/// precisely so this path can exist: the wrapper returned `Err` through `?`
399/// while the runner was still owned by the caller's stack frame, and dropping a
400/// runner mid-flight leaks its MCP children (#76 — fixed in `drive_child` at
401/// the time, and only there).
402/// Takes the runner rather than the engine: this future is live at two await
403/// points inside the driver's own, and moving the engine in would put a
404/// `State`-sized slot in both — which is how `run_non_interactive_with`'s
405/// future crosses `clippy::large_futures`.
406async fn timed_out(runner: EffectRunner, what: &str, deadline: Duration) -> Result<RunResult> {
407    runner.shutdown().await;
408    Err(anyhow::anyhow!(
409        "{what} exceeded {} seconds",
410        deadline.as_secs()
411    ))
412}
413
414/// The synthetic prompt that drives the `--output-schema` formatting turn.
415const FORMAT_PROMPT: &str = "Convert your final answer into a single JSON object that \
416conforms to the provided schema. Respond with only the JSON object - no prose, no code fences.";
417
418/// Run the dedicated `--output-schema` formatting turn: set the schema rider
419/// on state (`build_chat_request` drops all tools + adapters engage native
420/// constrained output), seed the format prompt, and drive to settled.
421async fn run_formatting_turn<O: StepObserver>(
422    engine: &mut Engine<EffectRunner, O>,
423    schema: serde_json::Value,
424    inbox: &mut Inbox<'_>,
425    policy: &DrivePolicy,
426) -> DriveExit {
427    engine.state_mut().output_schema = Some(schema);
428    engine.reduce(
429        chrono::Local::now(),
430        Msg::SubmitPrompt {
431            text: FORMAT_PROMPT.to_string(),
432            attachment_ids: vec![],
433        },
434    );
435    engine.drive(inbox, policy).await
436}
437
438/// Fold the formatting turn's outcome into the run result: the reshaped text
439/// replaces the response (code fences stripped), and `structured_output` is
440/// set only when the text parses AND validates. Every failure keeps the best
441/// available text and records an `output_schema:` error — a run that produced
442/// an answer never returns empty output.
443fn apply_schema_outcome(result: &mut RunResult, state: &State, schema: &serde_json::Value) {
444    // The formatting reply is the last assistant message; total tokens grew.
445    let formatted = build_result(state);
446    result.total_tokens = formatted.total_tokens;
447    if formatted.response.is_empty() || formatted.response == result.response {
448        result
449            .errors
450            .push("output_schema: formatting turn produced no output".to_string());
451        return;
452    }
453    let text = strip_code_fences(&formatted.response);
454    result.response = text.to_string();
455    let parsed: serde_json::Value = match serde_json::from_str(text) {
456        Ok(v) => v,
457        Err(e) => {
458            result
459                .errors
460                .push(format!("output_schema: response is not valid JSON: {e}"));
461            return;
462        },
463    };
464    let validator = match jsonschema::validator_for(schema) {
465        Ok(v) => v,
466        Err(e) => {
467            result
468                .errors
469                .push(format!("output_schema: schema did not compile: {e}"));
470            return;
471        },
472    };
473    if let Some(err) = validator.iter_errors(&parsed).next() {
474        result
475            .errors
476            .push(format!("output_schema: response does not conform: {err}"));
477        return;
478    }
479    result.structured_output = Some(parsed);
480}
481
482/// Trim a single wrapping markdown code fence (with optional info string) —
483/// models wrap JSON in ` ```json ` fences despite instructions.
484fn strip_code_fences(text: &str) -> &str {
485    let t = text.trim();
486    let Some(rest) = t.strip_prefix("```") else {
487        return t;
488    };
489    let Some(rest) = rest.split_once('\n').map(|(_, r)| r) else {
490        return t;
491    };
492    match rest.strip_suffix("```") {
493        Some(inner) => inner.trim(),
494        None => t,
495    }
496}
497
498/// Write one `RunEvent` as a JSON line to stdout (the NDJSON SDK stream).
499fn emit_run_event(event: &RunEvent) {
500    println!("{}", serde_json::to_string(event).unwrap_or_default());
501}
502
503/// Walk the committed message history and pull out the last
504/// assistant response + any errors encountered.
505fn build_result(state: &State) -> RunResult {
506    let mut out = RunResult {
507        total_tokens: state.session.cumulative_token_usage.total_tokens(),
508        session_id: state.session.conversation.id.clone(),
509        ..RunResult::default()
510    };
511
512    for msg in state.session.messages() {
513        for action in &msg.actions {
514            if let mermaid_domain::ActionResult::Error { error } = &action.result {
515                out.errors
516                    .push(format!("{}: {}", action.action_type, error));
517            }
518        }
519    }
520
521    // The final reply may span an auto-continue chain: the last assistant
522    // message plus any `Continuation`-kind messages leading up to it. Walk
523    // back to the chain's head, then join the segments in order with the
524    // same conservative resume-echo trim the transcript stitch uses —
525    // otherwise `--output text/json` silently dropped everything before the
526    // last continuation.
527    let messages = state.session.messages();
528    if let Some(last_idx) = messages
529        .iter()
530        .rposition(|m| m.role == MessageRole::Assistant)
531    {
532        let mut head_idx = last_idx;
533        while head_idx > 0
534            && messages[head_idx].kind == mermaid_model::models::ChatMessageKind::Continuation
535            && let Some(prev_idx) = messages[..head_idx]
536                .iter()
537                .rposition(|m| m.role == MessageRole::Assistant)
538            // Only step onto a real bubble: a compaction checkpoint or the
539            // empty error-carrier is never part of the reply chain.
540            && matches!(
541                messages[prev_idx].kind,
542                mermaid_model::models::ChatMessageKind::Normal
543                    | mermaid_model::models::ChatMessageKind::Continuation
544            )
545            && messages[prev_idx].tool_calls.is_none()
546        {
547            head_idx = prev_idx;
548        }
549        let mut response = String::new();
550        let mut reasoning: Option<String> = None;
551        for msg in messages[head_idx..=last_idx]
552            .iter()
553            .filter(|m| m.role == MessageRole::Assistant)
554        {
555            let skip = mermaid_model::utils::continuation_overlap(&response, &msg.content);
556            response.push_str(&msg.content[skip..]);
557            if let Some(t) = &msg.thinking {
558                match &mut reasoning {
559                    Some(r) => {
560                        r.push_str("\n\n");
561                        r.push_str(t);
562                    },
563                    None => reasoning = Some(t.clone()),
564                }
565            }
566        }
567        out.response = response;
568        out.reasoning = reasoning;
569    }
570
571    out
572}
573
574/// Render a `RunResult` in the requested output format.
575#[must_use]
576pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
577    match format {
578        OutputFormat::Text => {
579            if result.response.is_empty() && !result.errors.is_empty() {
580                result.errors.join("\n")
581            } else {
582                result.response.clone()
583            }
584        },
585        OutputFormat::Markdown => {
586            let mut out = result.response.clone();
587            if !result.errors.is_empty() {
588                out.push_str("\n\n---\n\n## Errors\n\n");
589                for e in &result.errors {
590                    out.push_str(&format!("- {e}\n"));
591                }
592            }
593            out
594        },
595        OutputFormat::Json => {
596            // Typed single-object form — the same shape as the streamed terminal
597            // `RunEvent::Result`, so the golden test pins this output too.
598            let event = RunEvent::Result {
599                response: result.response.clone(),
600                reasoning: result.reasoning.clone(),
601                total_tokens: result.total_tokens as u64,
602                errors: result.errors.clone(),
603                session_id: result.session_id.clone(),
604                structured_output: result.structured_output.clone(),
605            };
606            serde_json::to_string_pretty(&event).unwrap_or_default()
607        },
608        OutputFormat::Ndjson => {
609            // Events were streamed live during the run; nothing to print here.
610            String::new()
611        },
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::build_result;
618
619    #[test]
620    fn build_result_joins_an_auto_continued_reply() {
621        use mermaid_model::models::{ChatMessage, ChatMessageKind};
622        let mut state = mermaid_domain::State::new(
623            mermaid_domain::Config::default(),
624            std::path::PathBuf::from("/tmp/p"),
625            "ollama/test".to_string(),
626            chrono::Local::now(),
627            std::path::PathBuf::from("/tmp"),
628        );
629        state
630            .session
631            .append(ChatMessage::user("audit the widget"), state.now);
632        state.session.append(
633            ChatMessage::assistant("part one covers the resolver internals"),
634            state.now,
635        );
636        // The continuation echoes the tail of part one — joined output must
637        // carry it exactly once.
638        let mut cont = ChatMessage::assistant("the resolver internals, part two the adapters.");
639        cont.kind = ChatMessageKind::Continuation;
640        state.session.append(cont, state.now);
641
642        let result = build_result(&state);
643        assert_eq!(
644            result.response, "part one covers the resolver internals, part two the adapters.",
645            "headless output joins the whole chain, echo trimmed"
646        );
647    }
648
649    #[test]
650    fn build_result_without_chain_takes_the_last_reply() {
651        use mermaid_model::models::ChatMessage;
652        let mut state = mermaid_domain::State::new(
653            mermaid_domain::Config::default(),
654            std::path::PathBuf::from("/tmp/p"),
655            "ollama/test".to_string(),
656            chrono::Local::now(),
657            std::path::PathBuf::from("/tmp"),
658        );
659        state.session.append(ChatMessage::user("first"), state.now);
660        state
661            .session
662            .append(ChatMessage::assistant("earlier reply"), state.now);
663        state.session.append(ChatMessage::user("second"), state.now);
664        state
665            .session
666            .append(ChatMessage::assistant("final reply"), state.now);
667        let result = build_result(&state);
668        assert_eq!(result.response, "final reply");
669    }
670
671    #[test]
672    fn strip_code_fences_unwraps_single_fence_only() {
673        use super::strip_code_fences;
674        assert_eq!(strip_code_fences("{\"a\":1}"), "{\"a\":1}");
675        assert_eq!(strip_code_fences("```json\n{\"a\":1}\n```"), "{\"a\":1}");
676        assert_eq!(strip_code_fences("```\n{\"a\":1}\n```"), "{\"a\":1}");
677        // Unterminated fence -> left alone (don't mangle).
678        assert_eq!(
679            strip_code_fences("```json\n{\"a\":1}"),
680            "```json\n{\"a\":1}"
681        );
682        assert_eq!(strip_code_fences("  {\"a\":1}  "), "{\"a\":1}");
683    }
684
685    fn schema_state(reply: &str) -> mermaid_domain::State {
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("q"), state.now);
695        state
696            .session
697            .append(ChatMessage::assistant("the plain answer"), state.now);
698        state
699            .session
700            .append(ChatMessage::user("format it"), state.now);
701        state
702            .session
703            .append(ChatMessage::assistant(reply), state.now);
704        state
705    }
706
707    fn base_result() -> super::RunResult {
708        super::RunResult {
709            response: "the plain answer".to_string(),
710            ..super::RunResult::default()
711        }
712    }
713
714    #[test]
715    fn schema_outcome_valid_json_sets_structured_output() {
716        let schema = serde_json::json!({
717            "type": "object",
718            "properties": {"answer": {"type": "integer"}},
719            "required": ["answer"],
720        });
721        let state = schema_state("```json\n{\"answer\": 42}\n```");
722        let mut result = base_result();
723        super::apply_schema_outcome(&mut result, &state, &schema);
724        assert_eq!(result.response, "{\"answer\": 42}");
725        assert_eq!(
726            result.structured_output,
727            Some(serde_json::json!({"answer": 42}))
728        );
729        assert!(result.errors.is_empty(), "{:?}", result.errors);
730    }
731
732    #[test]
733    fn schema_outcome_invalid_json_keeps_text_and_records() {
734        let schema = serde_json::json!({"type": "object"});
735        let state = schema_state("not json at all");
736        let mut result = base_result();
737        super::apply_schema_outcome(&mut result, &state, &schema);
738        assert_eq!(result.response, "not json at all");
739        assert!(result.structured_output.is_none());
740        assert!(
741            result.errors.iter().any(|e| e.contains("not valid JSON")),
742            "{:?}",
743            result.errors
744        );
745    }
746
747    #[test]
748    fn schema_outcome_nonconforming_json_records_reason() {
749        let schema = serde_json::json!({
750            "type": "object",
751            "properties": {"answer": {"type": "integer"}},
752            "required": ["answer"],
753        });
754        let state = schema_state("{\"wrong\": true}");
755        let mut result = base_result();
756        super::apply_schema_outcome(&mut result, &state, &schema);
757        assert!(result.structured_output.is_none());
758        assert!(
759            result.errors.iter().any(|e| e.contains("does not conform")),
760            "{:?}",
761            result.errors
762        );
763    }
764
765    #[test]
766    fn schema_outcome_no_new_reply_keeps_original() {
767        // The formatting turn produced nothing new (same last assistant
768        // message) -> keep the agent's answer, record the failure.
769        let schema = serde_json::json!({"type": "object"});
770        use mermaid_model::models::ChatMessage;
771        let mut state = mermaid_domain::State::new(
772            mermaid_domain::Config::default(),
773            std::path::PathBuf::from("/tmp/p"),
774            "ollama/test".to_string(),
775            chrono::Local::now(),
776            std::path::PathBuf::from("/tmp"),
777        );
778        state.session.append(ChatMessage::user("q"), state.now);
779        state
780            .session
781            .append(ChatMessage::assistant("the plain answer"), state.now);
782        let mut result = base_result();
783        super::apply_schema_outcome(&mut result, &state, &schema);
784        assert_eq!(result.response, "the plain answer");
785        assert!(result.structured_output.is_none());
786        assert!(
787            result
788                .errors
789                .iter()
790                .any(|e| e.contains("produced no output")),
791            "{:?}",
792            result.errors
793        );
794    }
795
796    #[test]
797    fn result_event_carries_structured_output_to_subscribers() {
798        // The broadcast tee is exercised end-to-end by the daemon integration
799        // test; here pin the terminal event SHAPE subscribers rely on (a
800        // `result` type ends the stream).
801        let event = mermaid_domain::RunEvent::Result {
802            response: "done".to_string(),
803            reasoning: None,
804            total_tokens: 3,
805            errors: vec![],
806            session_id: "s".to_string(),
807            structured_output: None,
808        };
809        let wire = serde_json::to_string(&event).unwrap();
810        assert!(wire.contains("\"type\":\"result\""), "{wire}");
811        let (tx, mut rx) = tokio::sync::broadcast::channel::<mermaid_domain::RunEvent>(4);
812        tx.send(event.clone()).unwrap();
813        assert_eq!(rx.try_recv().unwrap(), event);
814    }
815
816    // When to stop driving now belongs to `StopWhen::Settled` in
817    // `crate::engine`, tested there against the real loop rather than against
818    // a boolean predicate standing in for it.
819}