orchestral-cli 0.4.0

A runtime for reliable, interactive AI agents.
Documentation
use super::*;
use serde_json::json;
use std::sync::{Arc, Mutex};

fn metadata(endpoint: &str) -> HostMetadata {
    HostMetadata {
        workspaces: Vec::new(), journal_location: String::new(), context: String::new(), context_budget: Some(8192),
        models: vec![serde_json::from_value(json!({"name":"same-name", "backend":"configured", "model":"profile-id"})).unwrap()],
        model_backend: serde_json::from_value(json!({"name":"cli-openai", "kind":"openai", "endpoint":endpoint,"config":{"auth":"none"}})).unwrap(),
        reasoning: ReasoningPreference::Default,
    }
}

struct Server(tokio::task::JoinHandle<()>);
impl Drop for Server {
    fn drop(&mut self) {
        self.0.abort();
    }
}

async fn server(responses: Vec<(axum::http::StatusCode, serde_json::Value)>) -> (String, Server) {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let address = listener.local_addr().unwrap();
    let responses = Arc::new(Mutex::new(std::collections::VecDeque::from(responses)));
    let app = axum::Router::new().route(
        "/proxy/v1/models",
        axum::routing::get(move |headers: axum::http::HeaderMap| {
            let responses = responses.clone();
            async move {
                assert!(!headers.contains_key("authorization"));
                let (status, body) = responses
                    .lock()
                    .unwrap()
                    .pop_front()
                    .expect("expected discovery call");
                (status, axum::Json(body))
            }
        }),
    );
    let task = tokio::spawn(async move {
        axum::serve(listener, app).await.unwrap();
    });
    (
        format!("http://{address}/proxy/v1/chat/completions"),
        Server(task),
    )
}

#[tokio::test]
async fn model_menu_refreshes_api_ids_and_keeps_profiles_explicit() {
    let (endpoint, _server) = server(vec![
        (
            axum::http::StatusCode::OK,
            json!({"data":[{"id":"same-name"},{"id":"other-api"}]}),
        ),
        (
            axum::http::StatusCode::OK,
            json!({"data":[{"id":"new-api"}]}),
        ),
    ])
    .await;
    let metadata = metadata(&endpoint);
    let first = discover(&metadata, "same-name").await.unwrap();
    assert_eq!(first.choices.len(), 3);
    assert!(first.choices.iter().any(|choice| matches!(serde_json::from_str::<Selection>(&choice.value).unwrap(), Selection::ApiModel { id, .. } if id == "same-name")));
    assert!(first.choices.iter().all(|choice| !matches!(
        serde_json::from_str::<Selection>(&choice.value).unwrap(),
        Selection::Profile { .. }
    )));
    let second = discover(&metadata, "same-name").await.unwrap();
    assert_eq!(second.choices[0].label, "new-api");
    assert!(!second
        .choices
        .iter()
        .any(|choice| choice.label == "same-name"));
}

#[tokio::test]
async fn empty_and_failed_discovery_do_not_masquerade_as_configured_models() {
    let (endpoint, _server) = server(vec![
        (axum::http::StatusCode::OK, json!({"data":[]})),
        (
            axum::http::StatusCode::SERVICE_UNAVAILABLE,
            json!({"error":"offline"}),
        ),
    ])
    .await;
    let metadata = metadata(&endpoint);
    assert!(
        format!("{:#}", discover(&metadata, "current").await.unwrap_err()).contains("no models")
    );
    assert!(format!("{:#}", discover(&metadata, "current").await.unwrap_err()).contains("503"));
    assert_eq!(profiles(&metadata, "current").choices[0].label, "same-name");
}

#[test]
fn api_selection_retains_connection_profile_and_reasoning_without_synthetic_backend_override() {
    let metadata = metadata("http://127.0.0.1:1/v1");
    let mut overrides = ModelOverrides {
        base_url: Some(metadata.model_backend.endpoint.clone().unwrap()),
        api_key_env: Some("EXPLICIT_KEY".into()),
        model_profile: Some("kept-sampling".into()),
        reasoning: Some(ReasoningPreference::Off),
        ..Default::default()
    };
    apply_selection(
        &metadata,
        "old",
        &mut overrides,
        Selection::ApiModel {
            backend: "cli-openai".into(),
            id: "same-name".into(),
        },
    )
    .unwrap();
    assert_eq!(overrides.model.as_deref(), Some("same-name"));
    assert_eq!(overrides.backend, None);
    assert_eq!(overrides.api_key_env.as_deref(), Some("EXPLICIT_KEY"));
    assert_eq!(overrides.model_profile.as_deref(), Some("kept-sampling"));
    assert!(overrides.base_url.is_some());
    assert_eq!(overrides.reasoning, Some(ReasoningPreference::Off));
    apply_selection(
        &metadata,
        "same-name",
        &mut overrides,
        Selection::Profile {
            name: "same-name".into(),
        },
    )
    .unwrap();
    assert_eq!(overrides.model.as_deref(), Some("profile-id"));
    assert_eq!(overrides.backend.as_deref(), Some("configured"));
    assert!(overrides.base_url.is_none() && overrides.api_key_env.is_none() && !overrides.no_auth);
}

#[test]
fn reasoning_selection_pins_api_model_and_rejects_stale_selection() {
    let metadata = metadata("http://127.0.0.1:1/v1");
    let mut overrides = ModelOverrides::default();
    apply_selection(
        &metadata,
        "api-model",
        &mut overrides,
        Selection::Reasoning {
            backend: "cli-openai".into(),
            model: "api-model".into(),
            value: ReasoningPreference::Off,
        },
    )
    .unwrap();
    assert_eq!(overrides.model.as_deref(), Some("api-model"));
    assert_eq!(overrides.reasoning, Some(ReasoningPreference::Off));
    assert!(apply_selection(
        &metadata,
        "new-api-model",
        &mut overrides,
        Selection::Reasoning {
            backend: "cli-openai".into(),
            model: "api-model".into(),
            value: ReasoningPreference::High,
        }
    )
    .is_err());
}

#[test]
fn binary_reasoning_menu_lists_only_default_on_off_and_marks_unknown_explicitly() {
    let metadata = metadata("http://127.0.0.1:1/v1");
    let mut model = DiscoveredModel {
        id: "model".into(),
        reasoning: Some(vec![
            ReasoningPreference::Default,
            ReasoningPreference::On,
            ReasoningPreference::Off,
        ]),
        thinking_default_enabled: Some(true),
    };
    let menu = reasoning_menu(&metadata, &model);
    assert_eq!(
        menu.choices
            .iter()
            .map(|choice| choice.label.as_str())
            .collect::<Vec<_>>(),
        ["default", "on", "off"]
    );
    model.reasoning = None;
    model.thinking_default_enabled = None;
    let menu = reasoning_menu(&metadata, &model);
    assert!(
        menu.detail.is_none(),
        "selectable choices must not become a read-only detail panel"
    );
    assert!(menu.choices[0].description.contains("not declared"));
    assert_eq!(menu.choices.len(), 1);
    assert!(menu.choices[0].description.contains("/reasoning <value>"));
}

#[test]
fn declared_efforts_round_trip_through_menu_and_selection_without_local_control_aliases() {
    let metadata = metadata("http://127.0.0.1:1/v1");
    for (name, label) in [
        ("ultra", "ultra"),
        ("future-next", "future-next"),
        ("HIGH", "HIGH"),
        ("on", "effort:on"),
        ("off", "effort:off"),
        ("default", "effort:default"),
        ("effort:on", "effort:effort:on"),
    ] {
        let model =
            crate::model_controls::discovered_model(orchestral_model_openai::DiscoveredModel {
                id: "model".into(),
                max_context_tokens: None,
                reasoning: Some(orchestral_model_openai::OpenAiReasoningCapabilities {
                    supported_efforts: Some(vec![name.parse().unwrap()]),
                    thinking: None,
                }),
            });
        let menu = reasoning_menu(&metadata, &model);
        assert_eq!(menu.choices.len(), 2);
        assert_eq!(menu.choices[1].label, label);
        let selection = serde_json::from_str(&menu.choices[1].value).unwrap();
        let mut overrides = ModelOverrides::default();
        apply_selection(&metadata, "model", &mut overrides, selection).unwrap();
        let control = crate::model_controls::openai_reasoning(
            &metadata.model_backend,
            overrides.reasoning.unwrap(),
        )
        .unwrap()
        .unwrap();
        assert_eq!(
            control,
            orchestral_model_openai::OpenAiReasoningControl::Effort(name.parse().unwrap())
        );
    }
}

#[test]
fn explicit_reasoning_command_needs_no_discovery_and_preserves_custom_effort() {
    let metadata = metadata("http://127.0.0.1:1/v1");
    for (command, expected) in [
        ("/reasoning future-next", "future-next"),
        ("/reasoning\tHIGH", "HIGH"),
        ("/reasoning effort:on", "on"),
    ] {
        let argument = reasoning_argument(command).unwrap();
        let selection = explicit_reasoning(&metadata, "model", argument).unwrap();
        let mut overrides = ModelOverrides::default();
        apply_selection(&metadata, "model", &mut overrides, selection).unwrap();
        assert_eq!(overrides.model.as_deref(), Some("model"));
        assert_eq!(
            overrides.reasoning,
            Some(ReasoningPreference::Custom(expected.into()))
        );
    }
    assert!(reasoning_argument("/reasoning").is_none());
    assert!(reasoning_argument("/reasoningful high").is_none());
    assert!(explicit_reasoning(&metadata, "model", "effort:").is_err());
}