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",
];
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);
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;
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;
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;
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",
);
}