rho-coding-agent 0.29.1

A lightweight agent harness inspired by Pi
Documentation
use pretty_assertions::assert_eq;
use serde_json::json;

use super::*;
use crate::provider_backend::{ContentBlock, Message, ToolCall, ToolSpec};

fn test_provider(model: &str) -> AnthropicProvider {
    let mut provider =
        AnthropicProvider::new(model.into(), "test-key".into(), |_| DEFAULT_MAX_TOKENS);
    provider.api_base = "https://example.test/v1".into();
    provider
}

fn request_body(provider: &AnthropicProvider) -> AnthropicRequest {
    let messages = [Message::user_text("hello")];
    provider
        .request_body(
            ModelRequest {
                messages: &messages,
                tools: &[],
                cancellation: Default::default(),
                prompt_cache_key: None,
            },
            false,
        )
        .unwrap()
}

#[test]
fn request_body_serializes_messages_tools_and_stream_flag() {
    let provider = test_provider("claude-sonnet-4-5");
    let body = provider
        .request_body(
            ModelRequest {
                messages: &[
                    Message::System("system prompt".into()),
                    Message::User(vec![ContentBlock::Text("hello".into())]),
                    Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
                        id: "toolu_1".into(),
                        name: "bash".into(),
                        arguments: json!({"command":"pwd"}),
                    })]),
                ],
                tools: &[ToolSpec {
                    name: "bash".into(),
                    description: "run command".into(),
                    input_schema: json!({"type":"object"}),
                }],
                cancellation: Default::default(),
                prompt_cache_key: Some("ignored"),
            },
            true,
        )
        .unwrap();

    let value = serde_json::to_value(body).unwrap();
    assert_eq!(value["model"], "claude-sonnet-4-5");
    assert_eq!(value["max_tokens"], DEFAULT_MAX_TOKENS);
    assert_eq!(value["system"][0]["text"], "system prompt");
    assert_eq!(
        value["system"][0]["cache_control"],
        json!({"type":"ephemeral"})
    );
    assert_eq!(value["stream"], true);
    assert_eq!(value["tools"][0]["name"], "bash");
    assert_eq!(
        value["tools"][0]["cache_control"],
        json!({"type":"ephemeral"})
    );
    assert!(value.get("cache_control").is_none());
    assert!(value.get("prompt_cache_key").is_none());
    assert_eq!(value["messages"][1]["content"][0]["type"], "tool_use");
}

#[test]
fn adaptive_thinking_uses_output_effort_without_a_token_budget() {
    let mut provider = test_provider("claude-opus-4-8");
    provider.set_reasoning(ReasoningLevel::Medium);

    let body = request_body(&provider);
    let value = serde_json::to_value(&body).unwrap();

    assert_eq!(body.max_tokens, DEFAULT_MAX_TOKENS);
    assert_eq!(
        body.thinking,
        Some(AnthropicThinkingConfig::Adaptive {
            display: "summarized"
        })
    );
    assert_eq!(
        body.output_config,
        Some(AnthropicOutputConfig { effort: "medium" })
    );
    assert_eq!(
        value["thinking"],
        json!({"type": "adaptive", "display": "summarized"})
    );
    assert_eq!(value["output_config"], json!({"effort": "medium"}));
    assert!(value["thinking"].get("budget_tokens").is_none());
}

#[test]
fn provider_context_replay_follows_effective_thinking_mode() {
    let adaptive = AnthropicThinkingConfig::Adaptive {
        display: "summarized",
    };
    let disabled = AnthropicThinkingConfig::Disabled;

    assert!(matches!(
        provider_context_replay(Some(&adaptive)),
        ProviderContextReplay::Enabled
    ));
    assert!(matches!(
        provider_context_replay(Some(&disabled)),
        ProviderContextReplay::Disabled
    ));
    assert!(matches!(
        provider_context_replay(None),
        ProviderContextReplay::Disabled
    ));
}

#[test]
fn reasoning_off_disables_adaptive_thinking_when_supported() {
    let provider = test_provider("claude-sonnet-5");

    let body = request_body(&provider);
    let value = serde_json::to_value(&body).unwrap();

    assert_eq!(body.thinking, Some(AnthropicThinkingConfig::Disabled));
    assert_eq!(body.output_config, None);
    assert_eq!(value["thinking"], json!({"type": "disabled"}));
}

#[test]
fn reasoning_off_is_rejected_when_adaptive_thinking_is_mandatory() {
    let mut provider = test_provider("claude-fable-5");

    assert!(!provider.set_reasoning(ReasoningLevel::Off));
    let body = request_body(&provider);

    assert_eq!(
        body.thinking,
        Some(AnthropicThinkingConfig::Adaptive {
            display: "summarized"
        })
    );
    assert_eq!(
        body.output_config,
        Some(AnthropicOutputConfig { effort: "low" })
    );
}

#[test]
fn legacy_thinking_still_reserves_answer_tokens() {
    let mut provider = test_provider("claude-sonnet-4-5");
    provider.set_reasoning(ReasoningLevel::Medium);

    let body = request_body(&provider);

    assert_eq!(body.max_tokens, DEFAULT_MAX_TOKENS);
    assert_eq!(
        body.thinking,
        Some(AnthropicThinkingConfig::Enabled {
            budget_tokens: DEFAULT_MAX_TOKENS - ANTHROPIC_ANSWER_RESERVE_TOKENS,
        })
    );
    assert_eq!(body.output_config, None);
}

#[test]
fn adaptive_effort_uses_only_levels_supported_by_each_model() {
    assert_eq!(
        adaptive_effort("claude-opus-4-6", ReasoningLevel::Minimal),
        "low"
    );
    assert_eq!(
        adaptive_effort("claude-opus-4-6", ReasoningLevel::Xhigh),
        "high"
    );
    assert_eq!(
        adaptive_effort("claude-opus-4-8-20260401", ReasoningLevel::Xhigh),
        "xhigh"
    );
}

#[test]
fn request_body_removes_top_level_schema_composition_from_tools() {
    let provider = test_provider("claude-sonnet-4-5");
    let body = provider
        .request_body(
            ModelRequest {
                messages: &[Message::user_text("hello")],
                tools: &[ToolSpec {
                    name: "edit_file".into(),
                    description: "edit files".into(),
                    input_schema: json!({
                        "type": "object",
                        "properties": {
                            "path": {"type": "string"},
                            "value": {
                                "anyOf": [
                                    {"type": "string"},
                                    {"type": "null"}
                                ]
                            }
                        },
                        "anyOf": [
                            {"required": ["path"]},
                            {"required": ["value"]}
                        ],
                        "oneOf": [{"type": "object"}],
                        "allOf": [{"type": "object"}]
                    }),
                }],
                cancellation: Default::default(),
                prompt_cache_key: None,
            },
            false,
        )
        .unwrap();

    let value = serde_json::to_value(body).unwrap();
    let schema = &value["tools"][0]["input_schema"];
    assert!(schema.get("anyOf").is_none());
    assert!(schema.get("oneOf").is_none());
    assert!(schema.get("allOf").is_none());
    assert!(schema["properties"]["value"].get("anyOf").is_some());
    assert_eq!(schema["properties"]["path"]["type"], "string");
}