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