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