Skip to main content

oino/
startup.rs

1//! Startup: services, session selection, model/tool options, runtime.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use oino_ai::{JsonValue, Model, ThinkingLevel};
7use oino_core::builtin::{SessionToolContext, builtin_tool_entries};
8use oino_core::extensions::api::{ExtensionMode, FlagType};
9use oino_core::runtime::{AgentSessionRuntime, SessionOptions, SessionSource, ToolSelection};
10use oino_core::safe_mode::{FailureEntry, FailureStage, SafeMode};
11use oino_core::services::{ResourceFlags, Services, ServicesOptions};
12use oino_models::resolver::{
13    CliModelRequest, ModelCatalog, ScopedModel, resolve_cli_model_in, resolve_model_scope_in,
14};
15use oino_session::SessionManager;
16
17use crate::cli::args::{Args, Diagnostic, normalize_session_name};
18use crate::cli::help::ExtensionFlag;
19use crate::loader::{CliLoader, EXTENSION_LOAD_FAILURE_HINT, LoaderContext};
20
21fn settings_snapshot(services: &Services) -> oino_settings::Settings {
22    services
23        .settings
24        .lock()
25        .unwrap_or_else(std::sync::PoisonError::into_inner)
26        .snapshot()
27}
28
29/// Resolved app mode.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum AppMode {
32    /// Terminal UI.
33    Interactive,
34    /// `-p` / `--mode text`.
35    Print,
36    /// `--mode json`.
37    Json,
38    /// `--mode rpc`.
39    Rpc,
40    /// `--mode acp`.
41    Acp,
42}
43
44impl AppMode {
45    /// R e s o l v e   A p p   M o d e.
46    #[must_use]
47    pub fn resolve(args: &Args, stdin_tty: bool, stdout_tty: bool) -> Self {
48        use crate::cli::args::OutputMode;
49        match args.mode {
50            Some(OutputMode::Rpc) => Self::Rpc,
51            Some(OutputMode::Json) => Self::Json,
52            Some(OutputMode::Acp) => Self::Acp,
53            _ if args.print || !stdin_tty || !stdout_tty => Self::Print,
54            _ => Self::Interactive,
55        }
56    }
57
58    const fn extension_mode(self) -> ExtensionMode {
59        match self {
60            Self::Interactive => ExtensionMode::Tui,
61            Self::Print => ExtensionMode::Print,
62            Self::Json => ExtensionMode::Json,
63            Self::Rpc => ExtensionMode::Rpc,
64            Self::Acp => ExtensionMode::Acp,
65        }
66    }
67}
68
69/// A startup failure: the lines to print to stderr, then exit 1.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct StartupError(pub Vec<String>);
72
73impl StartupError {
74    fn one(message: impl Into<String>) -> Self {
75        Self(vec![message.into()])
76    }
77}
78
79/// Everything a mode needs.
80pub struct Started {
81    /// The runtime.
82    pub runtime: Arc<AgentSessionRuntime>,
83    /// Diagnostics to report before running (warnings only; errors already failed).
84    pub diagnostics: Vec<Diagnostic>,
85    /// Extension flags (for `--help`).
86    pub extension_flags: Vec<ExtensionFlag>,
87    /// Sessions directory (interactive session picker).
88    pub sessions_root: PathBuf,
89    /// Project trust decision (interactive settings writes).
90    pub project_trusted: bool,
91}
92
93impl std::fmt::Debug for Started {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Started")
96            .field("diagnostics", &self.diagnostics)
97            .finish_non_exhaustive()
98    }
99}
100
101/// `validateForkFlags` + `validateSessionIdFlags`.
102///
103/// # Errors
104///
105/// Conflict messages.
106pub fn validate_session_flags(args: &Args) -> Result<(), StartupError> {
107    let flags = |pairs: &[(bool, &str)]| -> Vec<String> {
108        pairs
109            .iter()
110            .filter(|(set, _)| *set)
111            .map(|(_, name)| (*name).to_owned())
112            .collect()
113    };
114    if args.fork.is_some() {
115        let conflicts = flags(&[
116            (args.session.is_some(), "--session"),
117            (args.continue_session, "--continue"),
118            (args.resume, "--resume"),
119            (args.no_session, "--no-session"),
120        ]);
121        if !conflicts.is_empty() {
122            return Err(StartupError::one(format!(
123                "Error: --fork cannot be combined with {}",
124                conflicts.join(", ")
125            )));
126        }
127    }
128    if let Some(id) = &args.session_id {
129        let conflicts = flags(&[
130            (args.session.is_some(), "--session"),
131            (args.continue_session, "--continue"),
132            (args.resume, "--resume"),
133        ]);
134        if !conflicts.is_empty() {
135            return Err(StartupError::one(format!(
136                "Error: --session-id cannot be combined with {}",
137                conflicts.join(", ")
138            )));
139        }
140        oino_session::manager::validate_session_id(id)
141            .map_err(|e| StartupError::one(format!("Error: {e}")))?;
142    }
143    Ok(())
144}
145
146enum Resolved {
147    Path(PathBuf),
148    Global(PathBuf, String),
149    NotFound,
150}
151
152/// R e s o l v e   S e s s i o n   P a t h.
153#[allow(
154    clippy::case_sensitive_file_extension_comparisons,
155    reason = "`.jsonl` matches case-sensitively"
156)]
157fn resolve_session_path(arg: &str, cwd: &Path, root: &Path) -> Resolved {
158    if arg.contains('/') || arg.contains('\\') || arg.ends_with(".jsonl") {
159        let home = oino_settings::paths::home_dir();
160        return Resolved::Path(oino_settings::paths::resolve_path(
161            arg,
162            cwd,
163            home.as_deref(),
164            false,
165        ));
166    }
167    if let Ok(Some(path)) = SessionManager::find_by_id(cwd, arg, root)
168        && path.parent() == Some(&oino_session::manager::cwd_session_dir(root, cwd))
169    {
170        return Resolved::Path(path);
171    }
172    if let Ok(local) = SessionManager::list(cwd, root, None, None)
173        && let Some(found) = local.iter().find(|s| s.id.starts_with(arg))
174    {
175        return Resolved::Path(found.path.clone());
176    }
177    if let Ok(all) = SessionManager::list_all(root, None, None)
178        && let Some(found) = all
179            .iter()
180            .find(|s| s.id == arg)
181            .or_else(|| all.iter().find(|s| s.id.starts_with(arg)))
182    {
183        return Resolved::Global(found.path.clone(), found.cwd.clone());
184    }
185    Resolved::NotFound
186}
187
188/// Creates the session manager as a [`SessionSource`].
189fn session_source(
190    args: &Args,
191    metadata_only: bool,
192    cwd: &Path,
193    root: &Path,
194) -> Result<SessionSource, StartupError> {
195    if args.no_session || metadata_only {
196        return Ok(SessionSource::InMemory);
197    }
198    let not_found = |arg: &str| StartupError::one(format!("No session found matching '{arg}'"));
199    if let Some(fork) = &args.fork {
200        if args.session_id.is_some() {
201            return Err(StartupError::one(
202                "Error: --fork with --session-id is not supported yet",
203            ));
204        }
205        return match resolve_session_path(fork, cwd, root) {
206            Resolved::Path(path) | Resolved::Global(path, _) => Ok(SessionSource::Fork(path)),
207            Resolved::NotFound => Err(not_found(fork)),
208        };
209    }
210    if let Some(session) = &args.session {
211        return match resolve_session_path(session, cwd, root) {
212            Resolved::Path(path) => Ok(SessionSource::Open(path)),
213            Resolved::Global(_, other) => Err(StartupError(vec![
214                format!("Session found in different project: {other}"),
215                "Use --fork to copy it into the current directory.".to_owned(),
216            ])),
217            Resolved::NotFound => Err(not_found(session)),
218        };
219    }
220    if args.resume {
221        return Err(StartupError::one(
222            "Error: --resume needs the interactive session picker; use --session <id> or --continue",
223        ));
224    }
225    if args.continue_session {
226        return Ok(SessionSource::ContinueRecent);
227    }
228    if let Some(id) = &args.session_id {
229        // `--session-id`: open the session when it exists, create it with that id otherwise.
230        return match SessionManager::find_by_id(cwd, id, root) {
231            Ok(Some(path)) => Ok(SessionSource::Open(path)),
232            Ok(None) => Ok(SessionSource::NewWithId(id.clone())),
233            Err(error) => Err(StartupError::one(format!("Error: {error}"))),
234        };
235    }
236    Ok(SessionSource::New)
237}
238
239/// Whether the selected session already has messages.
240fn has_existing_session(source: &SessionSource, cwd: &Path, root: &Path) -> bool {
241    let path = match source {
242        SessionSource::Open(path) | SessionSource::Fork(path) => Some(path.clone()),
243        SessionSource::ContinueRecent => SessionManager::find_most_recent(cwd, root).ok().flatten(),
244        SessionSource::Resume(_)
245        | SessionSource::New
246        | SessionSource::NewWithId(_)
247        | SessionSource::InMemory => None,
248    };
249    path.and_then(|p| SessionManager::open(&p, root, Some(cwd)).ok())
250        .is_some_and(|m| !m.build_session_context().messages.is_empty())
251}
252
253struct ModelChoice {
254    model: Option<Model>,
255    thinking: Option<ThinkingLevel>,
256    scoped: Vec<ScopedModel>,
257}
258
259/// `buildSessionOptions` model part.
260fn choose_model(
261    args: &Args,
262    services: &Services,
263    has_existing: bool,
264    diagnostics: &mut Vec<Diagnostic>,
265) -> ModelChoice {
266    let models = &services.models;
267    let settings = settings_snapshot(services);
268    let mut choice = ModelChoice {
269        model: None,
270        thinking: None,
271        scoped: Vec::new(),
272    };
273    if let (Some(provider), None) = (&args.provider, &args.model) {
274        diagnostics.push(Diagnostic::error(format!(
275            "--provider requires --model (for example: --provider {provider} --model <pattern>)"
276        )));
277    }
278    if args.model.is_some() {
279        let request = CliModelRequest {
280            provider: args.provider.as_deref(),
281            model: args.model.as_deref(),
282            thinking: args.thinking,
283            inline_api_key: args.api_key.is_some(),
284        };
285        match resolve_cli_model_in(&request, models) {
286            Ok(Some(resolved)) => {
287                if let Some(warning) = resolved.warning {
288                    diagnostics.push(Diagnostic::warning(warning));
289                }
290                if args.thinking.is_none() {
291                    choice.thinking = resolved.thinking_level;
292                }
293                choice.model = Some(resolved.model);
294            }
295            Ok(None) => {}
296            Err(error) => diagnostics.push(Diagnostic::error(error.0)),
297        }
298    }
299    let patterns = args.models.clone().or_else(|| settings.enabled_models());
300    if let Some(patterns) = patterns.filter(|p| !p.is_empty()) {
301        let scope = resolve_model_scope_in(&patterns, models);
302        diagnostics.extend(
303            scope
304                .diagnostics
305                .into_iter()
306                .map(|d| Diagnostic::warning(d.message)),
307        );
308        choice.scoped = scope.scoped_models;
309    }
310    if choice.model.is_none() && !choice.scoped.is_empty() && !has_existing {
311        let saved = settings
312            .default_provider()
313            .zip(settings.default_model())
314            .and_then(|(p, id)| ModelCatalog::find(models, p, id));
315        let pick = saved
316            .and_then(|saved| {
317                choice
318                    .scoped
319                    .iter()
320                    .find(|s| s.model.provider == saved.provider && s.model.id == saved.id)
321            })
322            .or_else(|| choice.scoped.first())
323            .cloned();
324        if let Some(pick) = pick {
325            if args.thinking.is_none() {
326                choice.thinking = pick.thinking_level;
327            }
328            choice.model = Some(pick.model);
329        }
330    }
331    if args.thinking.is_some() {
332        choice.thinking = args.thinking;
333    }
334    choice
335}
336
337/// `--model` / `--provider` / `--models` for a new ACP session in `services`' cwd
338/// `buildSessionOptions` without an existing session); diagnostics go to stderr.
339pub(crate) fn acp_model_choice(args: &Args, services: &Services) -> oino_acp::ModelChoice {
340    let mut diagnostics = Vec::new();
341    let choice = choose_model(args, services, false, &mut diagnostics);
342    for line in crate::report_lines(&diagnostics) {
343        crate::eprintln_line(&line);
344    }
345    oino_acp::ModelChoice {
346        model: choice.model,
347        thinking_level: choice.thinking,
348        scoped_models: choice.scoped,
349    }
350}
351
352/// The docs root of the system prompt (`{docs_root}`): `OINO_DOCS_ROOT` > `<share>/docs` >
353/// legacy `~/.oino/bundled/oino.docs@*`; empty when absent (the docs section is then
354/// omitted, P-DOCS).
355pub(crate) fn docs_root(agent_dir: &Path) -> PathBuf {
356    oino_core::extensions::sources::docs_root(agent_dir).unwrap_or_default()
357}
358
359/// Resource flags from the command line.
360pub(crate) fn resource_flags(args: &Args, cwd: &Path) -> ResourceFlags {
361    ResourceFlags {
362        no_skills: args.no_skills,
363        no_prompt_templates: args.no_prompt_templates,
364        no_themes: args.no_themes,
365        no_context_files: args.no_context_files,
366        skill_paths: resolve_cli_paths(cwd, args.skills.as_ref()),
367        prompt_template_paths: resolve_cli_paths(cwd, args.prompt_templates.as_ref()),
368        theme_paths: resolve_cli_paths(cwd, args.themes.as_ref()),
369        system_prompt: args.system_prompt.clone(),
370        append_system_prompt: args.append_system_prompt.clone(),
371        no_extensions: args.no_extensions,
372        safe_mode: None,
373        offline: args.offline,
374    }
375}
376
377/// Tool selection from `--tools`, `--no-tools`, `--no-builtin-tools`.
378pub(crate) fn tool_selection(args: &Args) -> ToolSelection {
379    match &args.tools {
380        Some(tools) => ToolSelection::List(tools.clone()),
381        None if args.no_tools => ToolSelection::None,
382        None if args.no_builtin_tools => ToolSelection::NoBuiltin,
383        None => ToolSelection::Default,
384    }
385}
386
387fn resolve_cli_paths(cwd: &Path, paths: Option<&Vec<String>>) -> Vec<String> {
388    let home = oino_settings::paths::home_dir();
389    paths
390        .into_iter()
391        .flatten()
392        .map(|value| {
393            if value.contains("://") || value.starts_with("npm:") || value.starts_with("git:") {
394                value.clone()
395            } else {
396                oino_settings::paths::resolve_path(value, cwd, home.as_deref(), false)
397                    .display()
398                    .to_string()
399            }
400        })
401        .collect()
402}
403
404/// `applyExtensionFlagValues` checks over the loaded extensions.
405fn check_extension_flags(
406    args: &Args,
407    services: &Services,
408    diagnostics: &mut Vec<Diagnostic>,
409) -> (Vec<ExtensionFlag>, Vec<String>) {
410    let mut registered = Vec::new();
411    for extension in services.extensions.extensions() {
412        for flag in extension.state().flags.into_values() {
413            registered.push(ExtensionFlag {
414                extension_path: extension.instance.path(),
415                flag,
416            });
417        }
418    }
419    let mut unknown = Vec::new();
420    let mut booleans = Vec::new();
421    for (name, value) in &args.unknown_flags {
422        match registered.iter().find(|f| &f.flag.name == name) {
423            None => unknown.push(format!("--{name}")),
424            Some(flag) if flag.flag.kind == FlagType::Boolean => booleans.push(name.clone()),
425            Some(_) if value.is_string() => {}
426            Some(_) => diagnostics.push(Diagnostic::error(format!(
427                "Extension flag \"--{name}\" requires a value"
428            ))),
429        }
430    }
431    if !unknown.is_empty() {
432        diagnostics.push(Diagnostic::error(format!(
433            "Unknown option{}: {}",
434            if unknown.len() == 1 { "" } else { "s" },
435            unknown.join(", ")
436        )));
437    }
438    (registered, booleans)
439}
440
441/// Startup inputs that do not come from `argv`.
442#[derive(Clone, Debug)]
443pub struct Environment {
444    /// Process cwd.
445    pub cwd: PathBuf,
446    /// Agent dir.
447    pub agent_dir: PathBuf,
448}
449
450/// How many suspects one failed start may name (the state file keeps 8 entries total).
451const SUSPECT_LIMIT: usize = 3;
452
453/// Extension ids named by startup diagnostics (`Failed to load extension "id": …`,
454/// `Bundled extension "id": …`, `id: …` init/register failures), newest first, deduplicated.
455fn extension_suspects(lines: &[String]) -> Vec<(String, String)> {
456    let id = |text: &str| {
457        let text = text.trim();
458        let valid = |id: &str| {
459            !id.is_empty()
460                && id.split('.').all(|segment| {
461                    let mut chars = segment.chars();
462                    chars
463                        .next()
464                        .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
465                        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
466                })
467        };
468        for prefix in ["Failed to load extension \"", "Bundled extension \""] {
469            if let Some(rest) = text.strip_prefix(prefix)
470                && let Some((id, error)) = rest.split_once("\": ")
471                && valid(id)
472            {
473                return Some((id.to_owned(), error.to_owned()));
474            }
475        }
476        if let Some((id, error)) = text.split_once(": ")
477            && valid(id)
478            && !error.is_empty()
479        {
480            return Some((id.to_owned(), error.to_owned()));
481        }
482        None
483    };
484    let mut suspects = Vec::new();
485    for line in lines.iter().rev() {
486        if let Some((id, error)) = id(line)
487            && !suspects
488                .iter()
489                .any(|(known, _): &(String, String)| *known == id)
490        {
491            suspects.push((id, error));
492        }
493    }
494    suspects.truncate(SUSPECT_LIMIT);
495    suspects.reverse();
496    suspects
497}
498
499/// Records a failed start: extension-named diagnostics become the suspects, then the failure
500/// entry (X-SAFE).
501fn record_start_failure(
502    safe_mode: &mut SafeMode,
503    stage: FailureStage,
504    error: String,
505    suspect_lines: &[String],
506) {
507    for (id, error) in extension_suspects(suspect_lines) {
508        safe_mode.note_suspect(&id, error);
509    }
510    safe_mode.record_failure(FailureEntry::new(stage, error));
511}
512
513/// Creates services and the runtime.
514///
515/// # Errors
516///
517/// Startup diagnostics with errors, session resolution failures, missing models.
518#[allow(
519    clippy::too_many_lines,
520    reason = "linear startup; splitting hides the order"
521)]
522pub async fn start(args: &Args, mode: AppMode, env: &Environment) -> Result<Started, StartupError> {
523    let metadata_only = args.help || args.list_models.is_some();
524    // X-SAFE: load the failed-start counter, mark this start begun (a process death before
525    // the next write counts as a failure) and plan `-ne` semantics (providers stay).
526    let mut safe_mode = SafeMode::load(&env.agent_dir);
527    safe_mode.begin_start();
528    let plan = safe_mode.plan();
529    let loader = Arc::new(CliLoader::new(
530        args.extensions.as_deref().unwrap_or_default(),
531        &LoaderContext {
532            agent_dir: env.agent_dir.clone(),
533            cwd: env.cwd.clone(),
534            no_extensions: args.no_extensions,
535            exclude_extensions: args.exclude_extensions.clone().unwrap_or_default(),
536        },
537    ));
538    let mut diagnostics: Vec<Diagnostic> = loader.diagnostics().to_vec();
539    let mut flags = resource_flags(args, &env.cwd);
540    flags.safe_mode = Some(plan);
541    let services = match Services::create(ServicesOptions {
542        cwd: env.cwd.clone(),
543        agent_dir: env.agent_dir.clone(),
544        docs_root: docs_root(&env.agent_dir),
545        mode: mode.extension_mode(),
546        has_ui: matches!(mode, AppMode::Rpc | AppMode::Interactive),
547        flags,
548        extension_flag_values: args.unknown_flags.clone(),
549        loader: Arc::clone(&loader) as Arc<dyn oino_core::services::ExtensionLoader>,
550        approve: args.project_trust_override,
551        models: None,
552        settings: None,
553    })
554    .await
555    {
556        Ok(services) => services,
557        Err(e) => {
558            let error = format!("Error: {e}");
559            record_start_failure(
560                &mut safe_mode,
561                FailureStage::Services,
562                error.clone(),
563                &loader
564                    .diagnostics()
565                    .iter()
566                    .map(|d| d.message.clone())
567                    .collect::<Vec<_>>(),
568            );
569            return Err(StartupError::one(error));
570        }
571    };
572    diagnostics.extend(loader.load_errors());
573    diagnostics.extend(
574        services
575            .diagnostics
576            .iter()
577            .map(|d| Diagnostic::warning(d.clone())),
578    );
579    let (extension_flags, boolean_flags) = check_extension_flags(args, &services, &mut diagnostics);
580    let root = services.sessions_root(args.session_dir.as_deref().map(Path::new));
581    let project_trusted = services.project_trusted;
582    // Session selection and `--name` are user input, not host failures (X-SAFE): they clear
583    // the begun marker without counting.
584    let source = match session_source(args, metadata_only, &env.cwd, &root) {
585        Ok(source) => source,
586        Err(error) => {
587            safe_mode.end_start();
588            return Err(error);
589        }
590    };
591    let has_existing = has_existing_session(&source, &env.cwd, &root);
592    let choice = choose_model(args, &services, has_existing, &mut diagnostics);
593    if let Some(key) = &args.api_key {
594        match &choice.model {
595            Some(model) => services.models.set_runtime_api_key(&model.provider, key.clone()),
596            None => diagnostics.push(Diagnostic::error(
597                "--api-key requires a model to be specified via --model, --provider/--model, or --models",
598            )),
599        }
600    }
601    let name = match &args.name {
602        Some(raw) if normalize_session_name(raw).is_none() => {
603            safe_mode.end_start();
604            return Err(StartupError::one(
605                "Error: --name requires a non-empty value",
606            ));
607        }
608        Some(raw) => normalize_session_name(raw),
609        None => None,
610    };
611    let context = Arc::new(SessionToolContext::default());
612    let settings = settings_snapshot(&services);
613    let builtin_tools = builtin_tool_entries(
614        &services.cwd,
615        &services.agent_dir,
616        &settings,
617        Arc::clone(&context),
618    );
619    let options = SessionOptions {
620        source,
621        session_dir: args.session_dir.as_ref().map(PathBuf::from),
622        name,
623        model: choice.model,
624        thinking_level: choice.thinking,
625        scoped_models: choice.scoped,
626        tools: tool_selection(args),
627        exclude_tools: args.exclude_tools.clone().unwrap_or_default(),
628        builtin_tools,
629        custom_tools: Vec::new(),
630    };
631    let runtime = match AgentSessionRuntime::create(Arc::clone(&services), options).await {
632        Ok(runtime) => runtime,
633        Err(e) => {
634            let error = format!("Error: {e}");
635            let mut lines = diagnostics
636                .iter()
637                .map(|d| d.message.clone())
638                .collect::<Vec<_>>();
639            lines.extend(loader.load_errors().iter().map(|d| d.message.clone()));
640            lines.extend(services.diagnostics.iter().cloned());
641            record_start_failure(&mut safe_mode, FailureStage::Session, error.clone(), &lines);
642            return Err(StartupError::one(error));
643        }
644    };
645    // First session bound: the start succeeded (X-SAFE success reset).
646    safe_mode.record_success();
647    let session = runtime.session();
648    context.bind(&session);
649    let runner = session.runner();
650    for name in boolean_flags {
651        runner.set_flag_value(&name, JsonValue::Bool(true));
652    }
653    if metadata_only {
654        return Ok(Started {
655            runtime: Arc::new(runtime),
656            diagnostics,
657            extension_flags,
658            sessions_root: root,
659            project_trusted,
660        });
661    }
662    if diagnostics
663        .iter()
664        .any(|d| d.severity == crate::cli::args::Severity::Error)
665    {
666        let mut lines = crate::report_lines(&diagnostics);
667        if diagnostics
668            .iter()
669            .any(|d| d.message.contains("Failed to load extension"))
670        {
671            lines.push(EXTENSION_LOAD_FAILURE_HINT.to_owned());
672        }
673        runtime.dispose().await;
674        // Fatal startup diagnostics (a broken `-e` package, an invalid flag value) failed the
675        // start: record it with the extension suspects (X-SAFE).
676        let mut suspect_lines = diagnostics
677            .iter()
678            .map(|d| d.message.clone())
679            .collect::<Vec<_>>();
680        suspect_lines.extend(loader.load_errors().iter().map(|d| d.message.clone()));
681        let error = diagnostics
682            .iter()
683            .find(|d| d.severity == crate::cli::args::Severity::Error)
684            .map_or_else(
685                || "startup diagnostics reported errors".to_owned(),
686                |d| d.message.clone(),
687            );
688        record_start_failure(
689            &mut safe_mode,
690            FailureStage::Services,
691            error,
692            &suspect_lines,
693        );
694        return Err(StartupError(lines));
695    }
696    if mode != AppMode::Interactive && session.model().is_none() {
697        let message = runtime.model_fallback_message().map_or_else(
698            || {
699                oino_models::auth::guidance::no_models_available_message(
700                    &services.docs_root.join("docs"),
701                )
702            },
703            str::to_owned,
704        );
705        let mut lines = crate::report_lines(&diagnostics);
706        lines.push(message);
707        runtime.dispose().await;
708        // A model-configuration problem, not a host failure (X-SAFE).
709        safe_mode.end_start();
710        return Err(StartupError(lines));
711    }
712    Ok(Started {
713        runtime: Arc::new(runtime),
714        diagnostics,
715        extension_flags,
716        sessions_root: root,
717        project_trusted,
718    })
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn extension_suspects_names_extension_failures() {
727        let lines = vec![
728            "acme.notify: init failed: boom".to_owned(),
729            "Excluded extension \"acme.notify\" not loaded".to_owned(),
730            "Failed to load extension \"acme.kanboard\": no component.wasm".to_owned(),
731            "Bundled extension \"oino.docs\": extract failed".to_owned(),
732            "Unknown option: --nope".to_owned(),
733        ];
734        assert_eq!(
735            extension_suspects(&lines),
736            vec![
737                ("acme.notify".to_owned(), "init failed: boom".to_owned()),
738                ("acme.kanboard".to_owned(), "no component.wasm".to_owned()),
739                ("oino.docs".to_owned(), "extract failed".to_owned()),
740            ]
741        );
742    }
743
744    #[test]
745    fn extension_suspects_deduplicates_and_caps() {
746        let lines = vec![
747            "Failed to load extension \"a.b\": one".to_owned(),
748            "a.b: init failed: two".to_owned(),
749            "Failed to load extension \"c.d\": three".to_owned(),
750            "Failed to load extension \"e.f\": four".to_owned(),
751        ];
752        // Newest first, deduplicated by id (the newest error wins), capped at SUSPECT_LIMIT.
753        assert_eq!(
754            extension_suspects(&lines),
755            vec![
756                ("a.b".to_owned(), "init failed: two".to_owned()),
757                ("c.d".to_owned(), "three".to_owned()),
758                ("e.f".to_owned(), "four".to_owned()),
759            ]
760        );
761    }
762
763    #[test]
764    fn extension_suspects_ignores_unrelated_lines() {
765        let lines = vec![
766            "Duplicate extension id acme.notify ignored".to_owned(),
767            "Skipped extension package /tmp/x: bad manifest".to_owned(),
768            "No session found matching 'zzz'".to_owned(),
769            "Uppercase Id: not an extension".to_owned(),
770        ];
771        assert_eq!(extension_suspects(&lines).len(), 0);
772    }
773}