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 a distinct `--name` argv. Each plugin's
//! upstream advertises a single tool called `invoke`; the proxy prefixes
//! each one with the upstream's `serverInfo.name`, so the agent sees five
//! distinct tool names that all share an inner `invoke`.
//!
//! 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 persists tool calls to
//! `logs.assistant_response_tool_calls` (one row per call,
//! INSERT-then-UPDATE so every turn's calls survive across the
//! continuation chain). We read them back via `agents logs read all`
//! and collect each `AssistantResponsePart`'s `function_name`.
//!
//! The assertion: across all three turns, the deduplicated set of
//! tool-call names contains ≥2 unique entries. Determinism is
//! pinned by `SEED = 13` carried through both the spawn and the
//! continuation messages — the api's mock RNG produces the same
//! per-turn tool draws across runs.
//!
//! 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_NAMES: [&str; 5] = [
    "dup-alpha",
    "dup-bravo",
    "dup-charlie",
    "dup-delta",
    "dup-echo",
];
const SEED: i64 = 13;

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

#[tokio::test(flavor = "multi_thread")]
async fn duplicate_tool_names_routed_across_turns() {
    if cli_test_util::test_api_address().is_none() {
        eprintln!(
            "skipping duplicate_tool_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 name 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:?}",
    );
}