rho-coding-agent 2.10.0

A fast Rust agent harness with a small footprint and opinionated defaults
use std::sync::Arc;

use pretty_assertions::assert_eq;
use rho_sdk::{
    model::{Message, ModelIdentity},
    provider::{ModelProvider, ScriptedProvider, ScriptedTurn},
    SessionOptions, SystemPrompt, Workspace,
};

use super::{apply_conversation_switch, ConversationSwitch, SwitchNotice};
use crate::{
    app::{
        policy::AppPolicy,
        runtime_builder::{build_runtime, RuntimeBuildOptions},
    },
    compaction::CompactionConfig,
    permission::PermissionMode,
    prompt::{model_switch_context, ModelSwitchKind},
    tools::sdk_registry::AppToolSet,
};

fn identity(provider: &str, model: &str) -> ModelIdentity {
    ModelIdentity::new(provider, "test", model)
}

async fn switchable_session(
    history: Vec<Message>,
    context_window: Option<u64>,
) -> (
    rho_sdk::Rho,
    rho_sdk::Session,
    AppToolSet,
    Arc<dyn ModelProvider>,
) {
    let provider: Arc<dyn ModelProvider> = Arc::new(ScriptedProvider::new(
        identity("original", "start"),
        Vec::<ScriptedTurn>::new(),
    ));
    let tools = AppToolSet::disabled();
    let workspace = Workspace::new(std::env::current_dir().unwrap()).unwrap();
    let runtime = build_runtime(RuntimeBuildOptions {
        provider: Arc::clone(&provider),
        tools: tools.tools(),
        workspace,
        workspace_policy: AppPolicy::for_mode(PermissionMode::Auto, Default::default()),
        approval_session: None,
        system_prompt: SystemPrompt::None,
        reasoning: rho_sdk::ReasoningLevel::Medium,
        service_tier: None,
        compaction: CompactionConfig {
            auto_compact: true,
            threshold_percent: 80,
            target_percent: 50,
        },
        context_window,
        usage_purpose: "agent",
        usage_parent_session_id: None,
        usage_recording: Default::default(),
        hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
        hooks: None,
    })
    .unwrap();
    let session = runtime
        .session(SessionOptions::new().history(history))
        .await
        .unwrap();
    (runtime, session, tools, provider)
}

fn replacement(provider: &str, model: &str) -> Arc<dyn ModelProvider> {
    Arc::new(ScriptedProvider::new(
        identity(provider, model),
        Vec::<ScriptedTurn>::new(),
    ))
}

// Covers: a mid-session switch must move provider, reasoning, and compaction together
// Owner: conversation switch
#[tokio::test]
async fn mid_session_switch_updates_provider_reasoning_and_compaction() {
    let (_runtime, session, tools, previous_provider) = switchable_session(
        vec![
            Message::user_text("hello"),
            Message::assistant_text("there"),
        ],
        Some(1_000),
    )
    .await;
    let new_provider = replacement("replacement", "next");

    apply_conversation_switch(
        ConversationSwitch {
            prepared_prompt: None,
            session: &session,
            tools: &tools,
            previous_provider,
            new_provider: Arc::clone(&new_provider),
            new_reasoning: rho_sdk::ReasoningLevel::Low,
            auth: "test-auth",
            compaction: CompactionConfig {
                auto_compact: true,
                threshold_percent: 80,
                target_percent: 50,
            },
            context_window: Some(2_000),
            previous_context_window: Some(1_000),
            usage_recording: Default::default(),
        },
        SwitchNotice::SessionMessage,
    )
    .unwrap();

    assert_eq!(
        session.snapshot().provider(),
        &identity("replacement", "next")
    );
    assert_eq!(session.reasoning_level(), rho_sdk::ReasoningLevel::Low);
    assert_eq!(
        session.diagnostics().compaction_trigger_tokens(),
        Some(1_600)
    );
    let (expected_notice, _) = model_switch_context(
        ModelSwitchKind::Conversation,
        &crate::model_identity::PromptModel::from_sdk_identity(&identity("replacement", "next")),
    );
    assert_eq!(
        session.history().last(),
        Some(&Message::user_text(expected_notice))
    );
}

// Covers: an empty session must not inject a switch notice before the first prompt
// Owner: conversation switch
#[tokio::test]
async fn empty_session_switch_stays_silent() {
    let (_runtime, session, tools, previous_provider) = switchable_session(Vec::new(), None).await;

    apply_conversation_switch(
        ConversationSwitch {
            prepared_prompt: None,
            session: &session,
            tools: &tools,
            previous_provider,
            new_provider: replacement("replacement", "next"),
            new_reasoning: rho_sdk::ReasoningLevel::Low,
            auth: "test-auth",
            compaction: CompactionConfig::default(),
            context_window: None,
            previous_context_window: None,
            usage_recording: Default::default(),
        },
        SwitchNotice::SessionMessage,
    )
    .unwrap();

    assert_eq!(session.history(), Vec::<Message>::new());
    assert_eq!(
        session.snapshot().provider(),
        &identity("replacement", "next")
    );
}

// Covers: failed host persistence after provider replacement must restore the
// old prompt and transcript, not just the provider and reasoning selection.
// Owner: shared conversation switch transaction (Interactive and ACP)
#[tokio::test]
async fn failed_notice_restores_prompt_provider_and_history() {
    let history = vec![
        Message::System("original prompt".into()),
        Message::user_text("keep me"),
    ];
    let (_runtime, session, tools, previous_provider) =
        switchable_session(history.clone(), Some(1_000)).await;
    let home = tempfile::tempdir().unwrap();
    let running =
        crate::model_identity::PromptModel::from_sdk_identity(&identity("replacement", "next"));
    let mut prepared = crate::prompt::system_prompt_with_home_and_models(
        &[],
        home.path(),
        Some(home.path()),
        crate::prompt::PromptModels {
            running: &running,
            advisor: None,
        },
    );
    prepared.text = "new prompt".into();
    let mut fail_notice = |_: String, _: String| {
        Err(rho_sdk::Error::Persistence {
            message: "injected write failure".into(),
        })
    };
    assert!(apply_conversation_switch(
        ConversationSwitch {
            prepared_prompt: Some(&prepared),
            session: &session,
            tools: &tools,
            previous_provider,
            new_provider: replacement("replacement", "next"),
            new_reasoning: rho_sdk::ReasoningLevel::Low,
            auth: "test-auth",
            compaction: CompactionConfig::default(),
            context_window: Some(2_000),
            previous_context_window: Some(1_000),
            usage_recording: Default::default(),
        },
        SwitchNotice::WithDisplay(&mut fail_notice),
    )
    .is_err());
    assert_eq!(
        (
            session.history(),
            session.snapshot().provider().clone(),
            session.reasoning_level()
        ),
        (
            history,
            identity("original", "start"),
            rho_sdk::ReasoningLevel::Medium
        )
    );
}