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