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