starweaver-cli 0.10.0

Command-line interface for Starweaver
Documentation
#![allow(clippy::unwrap_used)]

use super::*;
use starweaver_agent::{SubagentRegistry, SubagentTask};

use crate::config::ConfigResolver;

fn test_config() -> CliConfig {
    let temp = tempfile::tempdir().unwrap();
    let cli =
        crate::args::parse(["starweaver-cli".to_string(), "diagnostics".to_string()]).unwrap();
    ConfigResolver::for_tests(temp.path())
        .resolve(&cli)
        .unwrap()
}

#[test]
fn shell_review_adjusted_approval_removes_shell_entries_only_when_enabled() {
    let mut config = test_config();
    let approval = vec![
        "shell".to_string(),
        "shell_exec".to_string(),
        "write".to_string(),
        "*".to_string(),
    ];

    assert_eq!(shell_review_adjusted_approval(&config, &approval), approval);

    config.shell_review.enabled = true;
    assert_eq!(
        shell_review_adjusted_approval(&config, &approval),
        vec!["write".to_string(), "*".to_string()]
    );
}

#[test]
fn default_registry_uses_scoped_context_and_host_io_toolset_names() {
    let config = test_config();
    let registry = default_registry(&config, &AgentSpec::default()).unwrap();

    assert!(registry.resolve_toolset("context").is_some());
    assert!(registry.resolve_toolset("host_io").is_some());
    assert!(registry.resolve_toolset("tools").is_none());
}

#[test]
fn resolve_profile_builds_configured_shell_review_handle() {
    let mut config = test_config();
    config.shell_review.enabled = true;
    config.shell_review.model = Some("local_echo".to_string());
    config.shell_review.on_needs_approval = "deny".to_string();
    config.shell_review.risk_threshold = "medium".to_string();
    config.shell_review.system_prompt = Some("Custom shell review prompt".to_string());

    let profile = resolve_profile(&config, Some("general")).unwrap();
    let Some(handle) = profile.shell_review else {
        panic!("shell review handle");
    };

    assert!(handle.config().enabled);
    assert_eq!(handle.config().on_needs_approval, ShellReviewAction::Deny);
    assert_eq!(handle.config().risk_threshold, ShellReviewRiskLevel::Medium);
    assert_eq!(
        handle.config().system_prompt.as_deref(),
        Some("Custom shell review prompt")
    );
    assert!(handle.config().model.is_some());
}

#[test]
fn subagent_model_settings_default_to_parent_settings() {
    let inherited = ModelSettings {
        provider_options: Some(json!({"store": false})),
        temperature: Some(0.2),
        ..ModelSettings::default()
    };

    let inherited_settings = resolve_subagent_model_settings(None, Some(&inherited)).unwrap();
    assert_eq!(inherited_settings, Some(inherited.clone()));

    let explicit_inherit =
        resolve_subagent_model_settings(Some(&json!("inherit")), Some(&inherited)).unwrap();
    assert_eq!(explicit_inherit, Some(inherited));
}

#[test]
fn subagent_model_settings_can_override_with_preset_or_inline_object() {
    let Some(preset) =
        resolve_subagent_model_settings(Some(&json!("openai_responses_high")), None).unwrap()
    else {
        panic!("settings preset");
    };
    assert_eq!(preset.provider_options.unwrap()["store"], false);

    let Some(inline) = resolve_subagent_model_settings(
        Some(&json!({
            "provider_options": {"store": false},
            "temperature": 0.1
        })),
        None,
    )
    .unwrap() else {
        panic!("inline settings");
    };
    assert_eq!(inline.provider_options.unwrap()["store"], false);
    assert_eq!(inline.temperature, Some(0.1));
}

#[test]
fn subagent_model_config_defaults_to_parent_config() {
    let inherited = ModelConfig {
        context_window: Some(123_456),
        ..ModelConfig::default()
    };

    let resolved =
        resolve_subagent_model_config(None, Some(&inherited), Some("claude_200k")).unwrap();
    assert_eq!(resolved.context, Some(inherited));
    assert_eq!(resolved.preset.as_deref(), Some("claude_200k"));
}

#[test]
fn subagent_model_config_can_override_with_preset_or_inline_object() {
    let preset = resolve_subagent_model_config(Some(&json!("claude_200k")), None, None).unwrap();
    assert_eq!(preset.context.unwrap().context_window, Some(200_000));
    assert_eq!(preset.preset.as_deref(), Some("claude_200k"));

    let inline = resolve_subagent_model_config(
        Some(&json!({"context_window": 42_000, "max_images": 3})),
        None,
        None,
    )
    .unwrap();
    let Some(context) = inline.context else {
        panic!("inline config");
    };
    assert_eq!(context.context_window, Some(42_000));
    assert_eq!(context.max_images, 3);
    assert!(inline.preset.is_none());
}

#[test]
fn openai_responses_ws_model_id_prefers_websocket_with_http_fallback() {
    let parsed = ProviderModelId::parse("openai-responses-ws:gpt-5").unwrap();

    assert_eq!(parsed.provider, "openai");
    assert_eq!(parsed.model_name, "gpt-5");
    assert_eq!(parsed.protocol, ProtocolFamily::OpenAiResponses);
    assert_eq!(parsed.gateway_name, None);
    assert_eq!(parsed.oauth_provider, None);
    assert_eq!(parsed.stream_transport, Some(ResponseStreamTransport::Auto));
}

#[test]
fn gateway_openai_responses_ws_model_id_prefers_websocket_with_http_fallback() {
    let parsed = ProviderModelId::parse("homelab@openai-responses-ws:gpt-5").unwrap();

    assert_eq!(parsed.provider, "openai");
    assert_eq!(parsed.model_name, "gpt-5");
    assert_eq!(parsed.protocol, ProtocolFamily::OpenAiResponses);
    assert_eq!(parsed.gateway_name.as_deref(), Some("homelab"));
    assert_eq!(parsed.oauth_provider, None);
    assert_eq!(parsed.stream_transport, Some(ResponseStreamTransport::Auto));
}

#[test]
fn openai_responses_ws_transport_default_can_be_overridden_by_settings() {
    let base = openai_responses_stream_transport_settings(ResponseStreamTransport::Auto);
    let overlay = ModelSettings {
        provider_settings: ProviderSettings {
            openai_responses: Some(OpenAiResponsesSettings {
                stream_transport: Some(ResponseStreamTransport::Http),
                ..OpenAiResponsesSettings::default()
            }),
            ..ProviderSettings::default()
        },
        ..ModelSettings::default()
    };

    let merged = base.merge(&overlay);

    assert_eq!(
        merged
            .provider_settings
            .openai_responses
            .unwrap()
            .stream_transport,
        Some(ResponseStreamTransport::Http)
    );
}

#[test]
fn anthropic_gateway_endpoint_uses_v1_when_base_url_has_no_sub_path() {
    let mut http_config = anthropic_http_config("test-key");
    let provider_config = ProviderConfig {
        base_url: Some("http://localhost:8090".to_string()),
        ..ProviderConfig::default()
    };

    apply_provider_http_config_overrides(&mut http_config, &provider_config);

    assert_eq!(http_config.base_url, "http://localhost:8090");
    assert_eq!(http_config.endpoint_path, "messages");
    assert_eq!(
        http_config.endpoint_url(),
        "http://localhost:8090/v1/messages"
    );
}

#[test]
fn anthropic_gateway_endpoint_uses_messages_when_base_url_has_sub_path() {
    let mut http_config = anthropic_http_config("test-key");
    let provider_config = ProviderConfig {
        base_url: Some("http://localhost:8090/abc".to_string()),
        ..ProviderConfig::default()
    };

    apply_provider_http_config_overrides(&mut http_config, &provider_config);

    assert_eq!(http_config.endpoint_path, "messages");
    assert_eq!(
        http_config.endpoint_url(),
        "http://localhost:8090/abc/messages"
    );
}

#[test]
fn anthropic_gateway_endpoint_keeps_explicit_endpoint_path() {
    let mut http_config = anthropic_http_config("test-key");
    let provider_config = ProviderConfig {
        base_url: Some("http://localhost:8090".to_string()),
        endpoint_path: Some("custom/messages".to_string()),
        ..ProviderConfig::default()
    };

    apply_provider_http_config_overrides(&mut http_config, &provider_config);

    assert_eq!(http_config.endpoint_path, "custom/messages");
    assert_eq!(
        http_config.endpoint_url(),
        "http://localhost:8090/custom/messages"
    );
}

#[test]
fn anthropic_endpoint_sub_path_detection_handles_root_and_existing_v1() {
    assert_eq!(
        anthropic_http_config("test-key")
            .with_base_url("https://gateway.example")
            .endpoint_url(),
        "https://gateway.example/v1/messages"
    );
    assert_eq!(
        anthropic_http_config("test-key")
            .with_base_url("https://gateway.example/")
            .endpoint_url(),
        "https://gateway.example/v1/messages"
    );
    assert_eq!(
        anthropic_http_config("test-key")
            .with_base_url("https://gateway.example/v1")
            .endpoint_url(),
        "https://gateway.example/v1/messages"
    );
}

#[tokio::test]
async fn configured_subagent_delegate_inherits_parent_model_settings_and_config() {
    let config = test_config();
    let inherited_settings = get_model_settings("openai_responses_high").unwrap();
    let Some(inherited_config) = resolve_inherited_model_config(Some("claude_200k")).unwrap()
    else {
        panic!("parent config");
    };
    let spec =
        starweaver_core::SubagentSpec::new("child", "Child helper", "You are a child helper.")
            .with_tools(Vec::new());
    let child_config = build_subagent_config(
        &config,
        &spec,
        "capture_subagent_inheritance",
        Some(&inherited_settings),
        Some(&inherited_config),
        Some("claude_200k"),
    )
    .unwrap();
    let registry = SubagentRegistry::new().with_subagent(child_config);
    let mut context = AgentContext::default();

    let result = registry
        .delegate_task("child", SubagentTask::new("hello"), &mut context)
        .await
        .unwrap();

    assert_eq!(result.output(), "captured: hello");
    let lifecycle_events = context
        .events
        .events()
        .iter()
        .filter(|event| event.kind == "subagent_started" || event.kind == "subagent_completed")
        .map(|event| event.kind.as_str())
        .collect::<Vec<_>>();
    assert_eq!(
        lifecycle_events,
        vec!["subagent_started", "subagent_completed"]
    );
}

#[test]
fn cli_model_catalog_exposes_query_only_session_tools() {
    let config = test_config();
    let names = list_default_tools(&config)
        .into_iter()
        .map(|tool| tool.name)
        .collect::<BTreeSet<_>>();
    for name in [
        "list_sessions",
        "get_session",
        "list_session_runs",
        "get_session_run",
        "replay_session_run",
    ] {
        assert!(names.contains(name), "missing CLI query tool {name}");
    }
    assert!(
        names.contains(starweaver_agent::ASK_USER_QUESTION_TOOL_NAME),
        "CLI must explicitly expose the clarifying-question tool"
    );
    for name in [
        "create_session",
        "update_session",
        "delete_session",
        "start_session_run",
        "steer_session_run",
        "interrupt_session_run",
    ] {
        assert!(
            !names.contains(name),
            "CLI must not expose control tool {name}"
        );
    }
}