rho-coding-agent 2.30.0

A fast Rust agent harness with a small footprint and opinionated defaults
//! Deferred MCP connect and OpenAPI loading, and pre-request system prompt
//! refresh.

#[cfg(test)]
#[path = "interactive_runtime_mcp_tests.rs"]
mod tests;

use futures_util::FutureExt;
use rho_sdk::SystemPrompt;

use super::InteractiveRuntime;
use crate::{
    app::tools_prompt::ToolSourcesOutcome, model_identity::PromptModel, prompt,
    tools::openapi::report::OpenApiReport,
};

enum StartupPromptRefresh {
    Rewritten,
    Unchanged,
    Frozen,
}

/// Independent startup changes consumed by the TUI. Catalog completion must not
/// reset an open MCP inventory picker.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct StartupHydrateChanges {
    pub(crate) tool_inventory_changed: bool,
    pub(crate) catalog_completed: bool,
    /// The OpenAPI load that just landed, so its problems reach the transcript.
    pub(crate) openapi: Option<OpenApiReport>,
}

impl StartupHydrateChanges {
    pub(crate) fn any(&self) -> bool {
        self.tool_inventory_changed || self.catalog_completed
    }
}

impl InteractiveRuntime {
    pub(crate) fn tool_sources_pending(&self) -> bool {
        self.pending_tool_sources.is_some()
    }

    pub(crate) fn startup_hydrate_pending(&self) -> bool {
        self.pending_tool_sources.is_some() || self.pending_catalog_names.is_some()
    }

    /// Whether a startup hydrate finished and is waiting for
    /// [`Self::poll_startup_hydrates`]. Unlike [`Self::startup_hydrate_pending`],
    /// this stays false while the work is still in flight, so the UI only
    /// redraws when there is something to apply.
    pub(crate) fn startup_hydrate_ready(&self) -> bool {
        self.pending_tool_sources
            .as_ref()
            .is_some_and(tokio::task::JoinHandle::is_finished)
            || self
                .pending_catalog_names
                .as_ref()
                .is_some_and(tokio::task::JoinHandle::is_finished)
    }

    pub(crate) fn cancel_startup_hydrates(&mut self) {
        if let Some(handle) = self.pending_tool_sources.take() {
            handle.abort();
        }
        if let Some(handle) = self.pending_catalog_names.take() {
            handle.abort();
        }
    }

    /// Apply finished MCP connect and catalog-name hydrates, retaining which
    /// inventory changed so consumers only refresh the affected UI.
    pub(crate) async fn poll_startup_hydrates(&mut self) -> anyhow::Result<StartupHydrateChanges> {
        let mut changes = StartupHydrateChanges::default();
        if let Some(handle) = self.pending_tool_sources.as_mut() {
            if handle.is_finished() {
                if let Some(handle) = self.pending_tool_sources.take() {
                    match handle.now_or_never() {
                        Some(Ok(outcome)) => {
                            self.apply_tool_sources(outcome).await?;
                            changes.tool_inventory_changed = true;
                            changes.openapi = Some(self.tools.openapi_report().clone());
                        }
                        // The load task died without reporting. Say so, or
                        // `/mcp` sits on `connecting` until the session ends.
                        Some(Err(error)) => {
                            self.mcp_report
                                .fail_connecting(&format!("MCP connect did not finish: {error}"));
                            changes.tool_inventory_changed = true;
                            changes.openapi = Some(OpenApiReport {
                                errors: vec![format!(
                                    "could not load OpenAPI sources: loading did not finish: {error}"
                                )],
                                ..OpenApiReport::default()
                            });
                        }
                        None => {}
                    }
                }
            }
        }
        if let Some(handle) = self.pending_catalog_names.as_mut() {
            if handle.is_finished() {
                if let Some(handle) = self.pending_catalog_names.take() {
                    let _ = handle.now_or_never();
                    // Capabilities may change even if the prompt is unchanged or frozen.
                    changes.catalog_completed = true;
                    if self.pending_tool_sources.is_none() {
                        match self.refresh_startup_system_prompt() {
                            StartupPromptRefresh::Rewritten => {
                                self.replace_history_system_prompt()?;
                            }
                            StartupPromptRefresh::Unchanged | StartupPromptRefresh::Frozen => {}
                        }
                    }
                }
            }
        }
        Ok(changes)
    }

    async fn apply_tool_sources(&mut self, outcome: ToolSourcesOutcome) -> anyhow::Result<()> {
        self.tools.attach_mcp(outcome.mcp);
        self.tools.attach_openapi(outcome.openapi);
        self.mcp_report = self.tools.mcp_report().clone();
        if let Some(template) = self.prompt_template.as_mut() {
            template.replace_tool_sources(&self.mcp_report, self.tools.openapi_report());
        }
        let refresh = self.refresh_startup_system_prompt();
        self.rebind_current_session().await?;
        self.remember_tool_list();
        match refresh {
            StartupPromptRefresh::Rewritten => self.replace_history_system_prompt()?,
            StartupPromptRefresh::Unchanged => {}
            StartupPromptRefresh::Frozen => {
                let notice =
                    prompt::tool_sources_context(&self.mcp_report, self.tools.openapi_report());
                if !notice.is_empty() {
                    self.append_user_context_with_display(notice.clone(), notice)?;
                }
            }
        }
        Ok(())
    }

    fn refresh_startup_system_prompt(&mut self) -> StartupPromptRefresh {
        if !self.may_rewrite_startup_prompt || self.live_context_warm {
            return StartupPromptRefresh::Frozen;
        }
        let Some(template) = self.prompt_template.as_ref() else {
            return StartupPromptRefresh::Unchanged;
        };
        // Catalog hydration changes display names, not the selected file. Keep
        // startup AGENTS/skills and the loaded model prompt without filesystem IO.
        let running = PromptModel::from_sdk_identity(&self.provider.provider().identity());
        let built = template.render(&running, self.sessions.prompt.loaded.as_ref());
        if self.sessions.prompt.system == SystemPrompt::Custom(built.text.clone()) {
            return StartupPromptRefresh::Unchanged;
        }
        // Hydration retains its startup lifetime until an explicit transition.
        self.sessions.prompt.adopt(
            crate::app::active_prompt::ActivePrompt::from_prepared(built),
            &self.diagnostics,
            self.tools.advisor(),
        );
        StartupPromptRefresh::Rewritten
    }

    fn replace_history_system_prompt(&mut self) -> anyhow::Result<()> {
        let SystemPrompt::Custom(prompt) = &self.sessions.prompt.system else {
            return Ok(());
        };
        crate::app::conversation_switch::replace_system_prompt(self.sessions.session(), prompt)?;
        Ok(())
    }
}