mod cli_test_util;
use std::path::PathBuf;
use std::process::Command;
use futures::StreamExt;
use objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional;
use objectiveai_sdk::cli::command::CommandExecutor;
use objectiveai_sdk::cli::command::agents::instances::list::{
Path as InstancesPath, Request as InstancesRequest, ResponseItem as InstancesItem,
};
use objectiveai_sdk::cli::command::agents::logs::read::all::{
AssistantResponsePart, Path as ReadAllPath, Request as ReadAllRequest,
ResponseItem as ReadAllItem, Target as ReadAllTarget,
};
use objectiveai_sdk::cli::command::agents::message::RequestMessage;
use objectiveai_sdk::cli::command::agents::selector::{AgentRef, AgentSelector};
use objectiveai_sdk::cli::command::agents::spawn::{
Path as SpawnPath, Request as SpawnRequest, RequestDangerousAdvanced,
ResponseItem as SpawnResponseItem,
};
use serde_json::{Value, json};
struct PluginGuard {
pid_file: PathBuf,
}
impl Drop for PluginGuard {
fn drop(&mut self) {
if let Ok(s) = std::fs::read_to_string(&self.pid_file) {
if let Ok(pid) = s.trim().parse::<u32>() {
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.status();
}
#[cfg(unix)]
{
let _ = Command::new("kill").args(["-9", &pid.to_string()]).status();
}
}
}
}
}
async fn set_test_mcp_timeout() {
let fs = objectiveai_cli::filesystem::Client::new(
Some(cli_test_util::objectiveai_dir()),
Some(cli_test_util::test_state_name()),
None::<String>,
None::<String>,
);
let mut config = fs.read_config().await.expect("read_config failed");
config.api().set_mcp_timeout_ms(5_000);
fs.write_config(&config).await.expect("write_config failed");
}
fn failing_agent(fail_at: &str, calls: Value) -> Value {
json!({
"upstream": "mock",
"output_mode": "instruction",
"client_objectiveai_mcp": {
"plugins": [{
"owner": "testorg",
"name": "test-mcp-plugin-failing",
"version": "1.0.0",
"executable": false,
"mcp_servers": [{
"name": "demo",
"arguments": { "fail-at": fail_at }
}]
}]
},
"calls": calls
})
}
fn agent_selector(agent_json: Value) -> AgentSelector {
AgentSelector::Ref {
agent: AgentRef::Resolved(
serde_json::from_value::<InlineAgentBaseWithFallbacksOrRemoteCommitOptional>(
agent_json,
)
.expect("inline failing-plugin agent must deserialize"),
),
}
}
fn spawn_request(agent: AgentSelector) -> SpawnRequest {
SpawnRequest {
path_type: SpawnPath::AgentsSpawn,
message: RequestMessage::Simple("use a tool".to_string()),
agent,
dangerous_advanced: Some(RequestDangerousAdvanced {
stream: Some(true),
seed: Some(1),
skip_lock: None,
}),
base: Default::default(),
}
}
async fn assert_spawn_errors_without_running(
executor: &cli_test_util::HangPreventingBinaryCommandExecutor,
agent_json: Value,
) {
let request = spawn_request(agent_selector(agent_json));
let stream = executor
.execute::<SpawnRequest, SpawnResponseItem>(request, None)
.await
.expect("cli execute must start");
let mut stream = std::pin::pin!(stream);
let mut saw_err = false;
let mut chunks = 0usize;
while let Some(item) = stream.next().await {
match item {
Ok(SpawnResponseItem::Chunk(chunk)) => {
chunks += 1;
if chunk.error.is_some() {
saw_err = true;
}
}
Ok(SpawnResponseItem::Id(_)) => {}
Err(_) => saw_err = true,
}
}
assert!(
saw_err,
"spawn must surface an error for a plugin that fails at MCP init",
);
assert_eq!(
chunks, 0,
"a fallback-less agent whose MCP init fails must not emit any completion chunk",
);
}
async fn spawn_and_count_tool_io(
executor: &cli_test_util::HangPreventingBinaryCommandExecutor,
agent_json: Value,
) -> (usize, usize) {
let items: Vec<SpawnResponseItem> =
cli_test_util::collect_stream(executor, spawn_request(agent_selector(agent_json))).await;
let full_aih = items
.iter()
.find_map(|item| match item {
SpawnResponseItem::Chunk(chunk)
if !chunk.agent_instance_hierarchy.is_empty() =>
{
Some(chunk.agent_instance_hierarchy.clone())
}
_ => None,
})
.expect("spawn must emit a Chunk carrying a non-empty agent_instance_hierarchy");
cli_test_util::wait_for_agent(executor, &full_aih).await;
let instances: Vec<InstancesItem> = cli_test_util::collect_stream(
executor,
InstancesRequest {
path_type: InstancesPath::AgentsInstancesList,
targets: vec![ReadAllTarget::Me],
base: Default::default(),
},
)
.await;
let listed_aih = instances
.first()
.expect("instances list must return the spawned agent")
.agent_instance_hierarchy
.clone();
let (parent, instance) = listed_aih
.rsplit_once('/')
.map(|(p, i)| (Some(p.to_string()), i.to_string()))
.unwrap_or((None, listed_aih.clone()));
let blocks: Vec<ReadAllItem> = cli_test_util::collect_stream(
executor,
ReadAllRequest {
path_type: ReadAllPath::AgentsLogsReadAll,
targets: vec![ReadAllTarget::Direct {
parent_agent_instance_hierarchy: parent,
agent_instance: instance,
}],
after_id: None,
limit: None,
base: Default::default(),
},
)
.await;
let mut tool_calls = 0usize;
let mut tool_responses = 0usize;
for block in &blocks {
match block {
ReadAllItem::AssistantResponse { parts, .. } => {
for part in parts {
if matches!(part, AssistantResponsePart::ToolCall { .. }) {
tool_calls += 1;
}
}
}
ReadAllItem::ToolResponse { .. } => tool_responses += 1,
_ => {}
}
}
(tool_calls, tool_responses)
}
#[tokio::test(flavor = "multi_thread")]
async fn plugin_mcp_connect_failure_errors_out() {
let base = cli_test_util::test_base_dir();
let pid_file = base.join("plugin-pid");
let _guard = PluginGuard {
pid_file: pid_file.clone(),
};
set_test_mcp_timeout().await;
let executor = cli_test_util::executor()
.await
.env("OAI_TEST_MCP_PID_FILE", pid_file.to_string_lossy().into_owned());
let agent = failing_agent("connect", json!([{ "tool_calls": [], "content": "" }]));
assert_spawn_errors_without_running(&executor, agent).await;
drop(_guard);
}
#[tokio::test(flavor = "multi_thread")]
async fn plugin_mcp_list_failure_errors_out() {
let base = cli_test_util::test_base_dir();
let pid_file = base.join("plugin-pid");
let _guard = PluginGuard {
pid_file: pid_file.clone(),
};
set_test_mcp_timeout().await;
let executor = cli_test_util::executor()
.await
.env("OAI_TEST_MCP_PID_FILE", pid_file.to_string_lossy().into_owned());
let agent = failing_agent("list", json!([{ "tool_calls": [], "content": "" }]));
assert_spawn_errors_without_running(&executor, agent).await;
drop(_guard);
}
#[tokio::test(flavor = "multi_thread")]
async fn plugin_mcp_call_failure_records_tool_response() {
let base = cli_test_util::test_base_dir();
let pid_file = base.join("plugin-pid");
let _guard = PluginGuard {
pid_file: pid_file.clone(),
};
set_test_mcp_timeout().await;
let executor = cli_test_util::executor()
.await
.env("OAI_TEST_MCP_PID_FILE", pid_file.to_string_lossy().into_owned());
let agent = failing_agent(
"call",
json!([
{
"tool_calls": [{ "name": "failsvr_doit", "arguments": "{}" }],
"content": ""
},
{ "tool_calls": [], "content": "done" }
]),
);
let (tool_calls, tool_responses) = spawn_and_count_tool_io(&executor, agent).await;
assert_eq!(
tool_calls, 1,
"expected exactly one assistant tool call (failsvr_doit)",
);
assert_eq!(
tool_responses, 1,
"expected exactly one tool response (the synthesized call-tool error)",
);
drop(_guard);
}
#[tokio::test(flavor = "multi_thread")]
async fn plugin_mcp_unlisted_tool_call_has_no_response() {
let base = cli_test_util::test_base_dir();
let pid_file = base.join("plugin-pid");
let _guard = PluginGuard {
pid_file: pid_file.clone(),
};
set_test_mcp_timeout().await;
let executor = cli_test_util::executor()
.await
.env("OAI_TEST_MCP_PID_FILE", pid_file.to_string_lossy().into_owned());
let agent = failing_agent(
"call",
json!([{
"tool_calls": [{ "name": "failsvr_nope", "arguments": "{}" }],
"content": ""
}]),
);
let (tool_calls, tool_responses) = spawn_and_count_tool_io(&executor, agent).await;
assert_eq!(
tool_calls, 1,
"expected exactly one assistant tool call (failsvr_nope)",
);
assert_eq!(
tool_responses, 0,
"an unlisted tool name is filtered before dispatch — no tool response",
);
drop(_guard);
}