objectiveai-cli 2.1.1

ObjectiveAI command-line interface and embeddable library
//! Five plugin installs, each pointing at the same `test-mcp-plugin-named`
//! fixture binary launched with an **identical** `--name` argv. Every
//! upstream's `serverInfo.name` therefore collides on the same string,
//! so the proxy must disambiguate the five colliding upstreams itself
//! to surface five distinct agent-visible tool names.
//!
//! A bare-bones plain mock agent runs three CLI turns against the
//! same `agent_instance_hierarchy`: one `agents spawn`, two
//! `agents message`. After every turn the cli writes its response
//! blob to `logs.agent_completion_responses`; we extract every
//! tool-call's `function.name` from
//! `body.messages[*].tool_calls[*].function.name` via `db query` and
//! dedupe.
//!
//! The assertion: across all three turns, the deduplicated set of
//! tool-call names contains **at least 2 unique entries**. That
//! proves the mock's RNG-driven tool selection hit at least two of
//! the five colliding-server-name upstreams — i.e., the proxy
//! produced ≥2 distinct surfaced names instead of collapsing the
//! five into one.
//!
//! Skip-gate: `OBJECTIVEAI_TEST_PORT` must point at a running test API.

mod cli_test_util;

use std::time::Duration;

use objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional;
use objectiveai_sdk::cli::command::agents::logs::read::all::{
    AssistantResponsePartType, Request as ReadAllRequest, ResponseItem as ReadAllItem,
    Target as ReadAllTarget,
};
use objectiveai_sdk::cli::command::agents::message::{
    MessageTarget, Request as MessageRequest,
    RequestDangerousAdvanced as MessageDangerousAdvanced, RequestMessage,
    ResponseItem as MessageResponseItem,
};
use objectiveai_sdk::cli::command::agents::spawn::{
    AgentResolution, AgentSpec, Request as SpawnRequest, RequestDangerousAdvanced,
    ResponseItem as SpawnResponseItem,
};
use serde_json::{Value, json};

const PLUGIN_INSTALL_NAMES: [&str; 5] = [
    "same-alpha",
    "same-bravo",
    "same-charlie",
    "same-delta",
    "same-echo",
];

/// Shared `--name` argv passed to every plugin binary so every
/// upstream's `serverInfo.name` echoes the same string.
const SHARED_SERVER_NAME: &str = "same";

const SEED: i64 = 42;

fn mock_agent() -> Value {
    let plugins: Vec<Value> = PLUGIN_INSTALL_NAMES
        .iter()
        .map(|name| {
            json!({
                "owner": "testorg",
                "name": name,
                "version": "1.0.0",
                "executable": false,
                "mcp_servers": [{
                    "name": "demo",
                    "arguments": { "name": SHARED_SERVER_NAME }
                }]
            })
        })
        .collect();
    json!({
        "upstream": "mock",
        "output_mode": "instruction",
        "client_objectiveai_mcp": { "plugins": plugins }
    })
}

#[tokio::test(flavor = "multi_thread")]
async fn duplicate_server_names_routed_across_turns() {
    if cli_test_util::test_api_address().is_none() {
        eprintln!(
            "skipping duplicate_server_names_routed_across_turns: OBJECTIVEAI_TEST_PORT not set"
        );
        return;
    }
    let base = cli_test_util::test_base_dir();

    let agent = AgentSpec::Resolved(
        serde_json::from_value::<InlineAgentBaseWithFallbacksOrRemoteCommitOptional>(mock_agent())
            .expect("mock agent must deserialize"),
    );
    let executor = cli_test_util::executor_with_base_dir(&base);

    // Turn 1: agents spawn
    let spawn = SpawnRequest {
        path_type: objectiveai_sdk::cli::command::agents::spawn::Path::AgentsSpawn,
        message: RequestMessage::Simple("use a tool".to_string()),
        agent: AgentResolution::Direct { agent_spec: agent },
        dangerous_advanced: Some(RequestDangerousAdvanced {
            stream: Some(true),
            seed: Some(SEED),
        }),
        jq: None,
    };
    let items: Vec<SpawnResponseItem> = cli_test_util::collect_stream(&executor, spawn).await;
    let spawn_aih = items
        .iter()
        .find_map(|i| match i {
            SpawnResponseItem::Chunk(c) if !c.agent_instance_hierarchy.is_empty() => {
                Some(c.agent_instance_hierarchy.clone())
            }
            _ => None,
        })
        .expect("agents spawn must emit a Chunk with a non-empty agent_instance_hierarchy");
    let target_instance = spawn_aih
        .rsplit_once('/')
        .map(|(_, i)| i.to_string())
        .unwrap_or_else(|| spawn_aih.clone());
    let target_parent = spawn_aih
        .rsplit_once('/')
        .map(|(p, _)| Some(p.to_string()))
        .unwrap_or(None);
    cli_test_util::wait_for_continuation(&executor, &spawn_aih, Duration::from_secs(180)).await;
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Turn 2: agents message — first continuation
    let msg1 = MessageRequest {
        path_type: objectiveai_sdk::cli::command::agents::message::Path::AgentsMessage,
        target: MessageTarget::Direct {
            parent_agent_instance_hierarchy: target_parent.clone(),
            agent_instance: target_instance.clone(),
        },
        message: RequestMessage::Simple("again".to_string()),
        enqueue: None,
        dangerous_advanced: Some(MessageDangerousAdvanced {
            stream: Some(true),
            seed: Some(SEED),
        }),
        jq: None,
    };
    let _items: Vec<MessageResponseItem> =
        cli_test_util::collect_stream(&executor, msg1).await;
    cli_test_util::wait_for_continuation(&executor, &spawn_aih, Duration::from_secs(180)).await;
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Turn 3: agents message — second continuation
    let msg2 = MessageRequest {
        path_type: objectiveai_sdk::cli::command::agents::message::Path::AgentsMessage,
        target: MessageTarget::Direct {
            parent_agent_instance_hierarchy: target_parent.clone(),
            agent_instance: target_instance.clone(),
        },
        message: RequestMessage::Simple("one more".to_string()),
        enqueue: None,
        dangerous_advanced: Some(MessageDangerousAdvanced {
            stream: Some(true),
            seed: Some(SEED),
        }),
        jq: None,
    };
    let _items: Vec<MessageResponseItem> =
        cli_test_util::collect_stream(&executor, msg2).await;
    cli_test_util::wait_for_continuation(&executor, &spawn_aih, Duration::from_secs(180)).await;

    // Walk every assistant block from `agents logs read all`,
    // filter to `ToolCall` parts, collect `function_name`.
    let read_all = ReadAllRequest {
        path_type: objectiveai_sdk::cli::command::agents::logs::read::all::Path::AgentsLogsReadAll,
        targets: vec![ReadAllTarget::Direct {
            parent_agent_instance_hierarchy: None,
            agent_instance: target_instance.clone(),
        }],
        after_id: None,
        limit: None,
        jq: None,
    };
    let blocks: Vec<ReadAllItem> = cli_test_util::collect_stream(&executor, read_all).await;
    let mut names: Vec<String> = Vec::new();
    for block in &blocks {
        if let ReadAllItem::AssistantResponse { parts, .. } = block {
            for part in parts {
                if matches!(part.r#type, AssistantResponsePartType::ToolCall) {
                    names.push(part.function_name.clone());
                }
            }
        }
    }
    assert!(
        !names.is_empty(),
        "expected ≥1 tool-call row across the three turns; got none — \
         the mock didn't pick any tools (seed/mode mismatch?)"
    );
    let unique: std::collections::HashSet<String> = names.into_iter().collect();
    assert!(
        unique.len() >= 2,
        "expected ≥2 unique tool-call names across all three turns, got {unique:?} — \
         the proxy may be collapsing colliding-server-name upstreams instead of \
         disambiguating them",
    );
}