#![cfg(all(feature = "acp-experimental", unix))]
use std::path::PathBuf;
use std::time::Duration;
use protocol_droid::adapters::secondary::acp::{AcpStdioPeer, SpawnSpec};
use protocol_droid::domain::acp::implementation::Implementation;
use protocol_droid::domain::acp::initialize::{AuthMethod, ClientCapabilities, InitializeRequest};
use protocol_droid::domain::acp::method::{AcpMethod, AgentMethod};
use protocol_droid::domain::acp::session::{
McpServer, NewSessionRequest, PromptRequest, PromptResponse, SessionId,
};
use serde_json::Value;
fn reference_agent_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp_reference_agent.mjs")
}
fn find_node() -> Option<String> {
let path = std::env::var_os("PATH")?;
let candidates: &[&str] = if cfg!(windows) {
&["node.exe", "node.cmd", "node.bat"]
} else {
&["node"]
};
for directory in std::env::split_paths(&path) {
for candidate in candidates {
let full = directory.join(candidate);
if full.is_file() {
return Some(full.to_string_lossy().into_owned());
}
}
}
None
}
fn ensure_node() -> Result<String, String> {
find_node().ok_or_else(|| {
"the ACP stdio interop test is skipped because `node` is not on PATH; \
install Node 18+ to run the interop suite"
.to_owned()
})
}
fn sample_initialize_request() -> InitializeRequest {
InitializeRequest {
protocol_version:
protocol_droid::domain::acp::protocol_version::AcpProtocolVersion::CURRENT,
client_capabilities: ClientCapabilities::default(),
client_info: Some(Implementation::protocol_droid("acp-stdio-interop", "0.1.0")),
}
}
#[tokio::test]
async fn initialize_round_trip_against_a_vanilla_node_agent() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let reply = peer
.send_request(
AcpMethod::Agent(AgentMethod::Initialize),
sample_initialize_request(),
)
.await
.map_err(|error| format!("initialize reply: {error}"))?;
let reply_id = reply
.id
.as_u64()
.ok_or_else(|| format!("reply id is not a u64: {}", reply.id))?;
assert!(reply_id >= 1, "the reply id is {reply_id}");
let response: protocol_droid::domain::acp::initialize::InitializeResponse =
serde_json::from_value(reply.result)
.map_err(|error| format!("decoding InitializeResponse: {error}"))?;
assert_eq!(
response.protocol_version,
protocol_droid::domain::acp::protocol_version::AcpProtocolVersion::CURRENT,
);
let auth_methods = response
.auth_methods
.as_ref()
.ok_or_else(|| "the reference agent advertised no auth methods".to_owned())?;
let method = auth_methods
.first()
.ok_or_else(|| "the auth methods list is empty".to_owned())?;
match method {
AuthMethod::Terminal(terminal) => {
assert_eq!(terminal.id, "dev-terminal");
assert_eq!(terminal.name, "In-process dev terminal");
}
AuthMethod::Agent(_) => {
return Err("expected the terminal auth method, got the agent variant".to_owned());
}
}
let info = response
.agent_info
.as_ref()
.ok_or_else(|| "the reference agent advertised no agentInfo".to_owned())?;
assert_eq!(info.name, "acp-reference-agent");
assert_eq!(info.version, "1.25.0");
drop(peer);
Ok(())
}
#[tokio::test]
async fn an_unknown_method_returns_a_typed_json_rpc_error() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let outcome = peer
.send_request(
AcpMethod::Agent(AgentMethod::SessionList),
serde_json::json!({}),
)
.await;
match outcome {
Err(protocol_droid::adapters::secondary::acp::AcpPeerError::JsonRpc { code, message }) => {
assert_eq!(code, -32601);
assert_eq!(message, "Method not found");
}
Err(other) => {
return Err(format!("expected a JSON-RPC error, got {other}"));
}
Ok(reply) => {
return Err(format!(
"expected a JSON-RPC error, got a success reply: {reply:?}"
));
}
}
drop(peer);
Ok(())
}
#[tokio::test]
async fn a_minimal_initialize_request_round_trips() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let request = InitializeRequest {
protocol_version:
protocol_droid::domain::acp::protocol_version::AcpProtocolVersion::CURRENT,
client_capabilities: ClientCapabilities::default(),
client_info: None,
};
let reply = peer
.send_request(AcpMethod::Agent(AgentMethod::Initialize), request)
.await
.map_err(|error| format!("initialize reply: {error}"))?;
let response: protocol_droid::domain::acp::initialize::InitializeResponse =
serde_json::from_value(reply.result)
.map_err(|error| format!("decoding InitializeResponse: {error}"))?;
assert_eq!(
response.protocol_version,
protocol_droid::domain::acp::protocol_version::AcpProtocolVersion::CURRENT,
);
drop(peer);
Ok(())
}
#[tokio::test]
async fn the_peer_spawns_against_node_and_drops_cleanly() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
tokio::time::sleep(Duration::from_millis(50)).await;
drop(peer);
Ok(())
}
#[tokio::test]
async fn the_auth_method_discriminator_is_the_type_field() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let reply = peer
.send_request(
AcpMethod::Agent(AgentMethod::Initialize),
sample_initialize_request(),
)
.await
.map_err(|error| format!("initialize reply: {error}"))?;
let auth_methods = reply
.result
.get("authMethods")
.and_then(Value::as_array)
.ok_or_else(|| "the reply does not carry an authMethods array".to_owned())?;
let method = auth_methods
.first()
.ok_or_else(|| "the authMethods array is empty".to_owned())?;
let kind = method
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| "the auth method has no `type` field".to_owned())?;
assert_eq!(kind, "terminal", "the wire discriminator is `terminal`");
let id = method
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| "the auth method has no `id` field".to_owned())?;
assert_eq!(id, "dev-terminal", "the wire `id` is the agent's value");
drop(peer);
Ok(())
}
#[tokio::test]
async fn a_typed_session_new_request_round_trips() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let request = NewSessionRequest {
cwd: "/tmp/example".to_owned(),
additional_directories: None,
mcp_servers: vec![McpServer::Stdio(
protocol_droid::domain::acp::session::McpServerStdio {
name: "stdio-server".to_owned(),
command: "/usr/local/bin/mcp-server".to_owned(),
args: vec!["--quiet".to_owned()],
env: vec![],
},
)],
};
let response: protocol_droid::domain::acp::session::NewSessionResponse = peer
.send_typed_request(AcpMethod::Agent(AgentMethod::SessionNew), request)
.await
.map_err(|error| format!("session/new reply: {error}"))?;
let expected_id = SessionId::new("fixture-1");
assert_eq!(response.session_id, expected_id);
drop(peer);
Ok(())
}
#[tokio::test]
async fn a_session_update_notification_round_trips() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let request = NewSessionRequest {
cwd: "/tmp/example".to_owned(),
additional_directories: None,
mcp_servers: vec![],
};
let _: protocol_droid::domain::acp::session::NewSessionResponse = peer
.send_typed_request(AcpMethod::Agent(AgentMethod::SessionNew), request)
.await
.map_err(|error| format!("session/new reply: {error}"))?;
let notification = tokio::time::timeout(Duration::from_secs(2), peer.recv_notification())
.await
.map_err(|error| format!("waiting for the notification: {error}"))?
.ok_or_else(|| "the notification stream closed without a notification".to_owned())?;
assert_eq!(notification.session_id, SessionId::new("fixture-1"));
match ¬ification.update {
protocol_droid::domain::acp::notification::SessionUpdate::UserMessageChunk(chunk) => {
use protocol_droid::domain::acp::content::ContentBlock;
match &chunk.content {
ContentBlock::Text(text) => {
assert_eq!(text.text, "Echo: hello");
}
other => {
return Err(format!("expected a text chunk, got {other:?}"));
}
}
assert_eq!(
chunk
.message_id
.as_ref()
.map(protocol_droid::domain::acp::notification::MessageId::to_string)
.as_deref(),
Some("msg-1"),
);
}
other => {
return Err(format!("expected a user_message_chunk, got {other:?}"));
}
}
drop(peer);
Ok(())
}
#[tokio::test]
async fn a_typed_session_cancel_notification_writes_cleanly() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let notification = protocol_droid::domain::acp::session::CancelNotification {
session_id: SessionId::new("s"),
};
peer.send_notification(AcpMethod::Agent(AgentMethod::SessionCancel), notification)
.await
.map_err(|error| format!("session/cancel write: {error}"))?;
tokio::time::sleep(Duration::from_millis(50)).await;
drop(peer);
Ok(())
}
#[tokio::test]
async fn a_session_prompt_turn_streams_updates_and_returns_stop_reason() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let _: protocol_droid::domain::acp::initialize::InitializeResponse = peer
.send_typed_request(
AcpMethod::Agent(AgentMethod::Initialize),
sample_initialize_request(),
)
.await
.map_err(|error| format!("initialize reply: {error}"))?;
let new_session: protocol_droid::domain::acp::session::NewSessionResponse = peer
.send_typed_request(
AcpMethod::Agent(AgentMethod::SessionNew),
NewSessionRequest {
cwd: "/tmp/example".to_owned(),
additional_directories: None,
mcp_servers: vec![],
},
)
.await
.map_err(|error| format!("session/new reply: {error}"))?;
let session_id = new_session.session_id;
let prompt = PromptRequest {
session_id: session_id.clone(),
prompt: vec![protocol_droid::domain::acp::content::ContentBlock::Text(
protocol_droid::domain::acp::content::TextContent {
text: "hi".to_owned(),
annotations: None,
meta: std::collections::BTreeMap::new(),
},
)],
};
let response: PromptResponse = peer
.send_typed_request(AcpMethod::Agent(AgentMethod::SessionPrompt), prompt)
.await
.map_err(|error| format!("session/prompt reply: {error}"))?;
assert_eq!(
response.stop_reason,
protocol_droid::domain::acp::session::StopReason::EndTurn
);
let mut assembled_text = String::new();
let mut chunks_seen: u8 = 0;
while chunks_seen < 2 {
let notification = tokio::time::timeout(Duration::from_secs(2), peer.recv_notification())
.await
.map_err(|error| format!("waiting for the notification: {error}"))?
.ok_or_else(|| "the notification stream closed without a notification".to_owned())?;
assert_eq!(notification.session_id, session_id);
match notification.update {
protocol_droid::domain::acp::notification::SessionUpdate::AgentMessageChunk(chunk) => {
use protocol_droid::domain::acp::content::ContentBlock;
match chunk.content {
ContentBlock::Text(text) => assembled_text.push_str(&text.text),
other => {
return Err(format!("expected a text chunk, got {other:?}"));
}
}
chunks_seen = chunks_seen.saturating_add(1);
}
protocol_droid::domain::acp::notification::SessionUpdate::UserMessageChunk(_)
| protocol_droid::domain::acp::notification::SessionUpdate::AgentThoughtChunk(_) => {
}
other => {
return Err(format!("expected an agent_message_chunk, got {other:?}"));
}
}
}
assert_eq!(assembled_text, "Hello, world!");
Ok(())
}
#[tokio::test]
async fn a_select_loop_renders_notifications_and_returns_a_reply() -> Result<(), String> {
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let _: protocol_droid::domain::acp::initialize::InitializeResponse = peer
.send_typed_request(
AcpMethod::Agent(AgentMethod::Initialize),
sample_initialize_request(),
)
.await
.map_err(|error| format!("initialize reply: {error}"))?;
let new_session: protocol_droid::domain::acp::session::NewSessionResponse = peer
.send_typed_request(
AcpMethod::Agent(AgentMethod::SessionNew),
NewSessionRequest {
cwd: "/tmp/example".to_owned(),
additional_directories: None,
mcp_servers: vec![],
},
)
.await
.map_err(|error| format!("session/new reply: {error}"))?;
let session_id = new_session.session_id;
let prompt = PromptRequest {
session_id: session_id.clone(),
prompt: vec![protocol_droid::domain::acp::content::ContentBlock::Text(
protocol_droid::domain::acp::content::TextContent {
text: "hi".to_owned(),
annotations: None,
meta: std::collections::BTreeMap::new(),
},
)],
};
let prompt_fut = peer.send_typed_request::<_, PromptResponse>(
AcpMethod::Agent(AgentMethod::SessionPrompt),
prompt,
);
tokio::pin!(prompt_fut);
let mut assembled_text = String::new();
let mut chunks_seen: u8 = 0;
let response: PromptResponse = loop {
let notif_fut = peer.recv_notification();
tokio::pin!(notif_fut);
tokio::select! {
biased;
notification = &mut notif_fut => {
match notification {
Some(notification) => {
assert_eq!(notification.session_id, session_id);
if let protocol_droid::domain::acp::notification::SessionUpdate::AgentMessageChunk(chunk) = notification.update {
use protocol_droid::domain::acp::content::ContentBlock;
if let ContentBlock::Text(text) = chunk.content {
assembled_text.push_str(&text.text);
chunks_seen = chunks_seen.saturating_add(1);
}
}
}
None => return Err("the notification stream closed".to_owned()),
}
}
response = &mut prompt_fut => {
break response.map_err(|error| format!("session/prompt reply: {error}"))?;
}
}
};
assert_eq!(
response.stop_reason,
protocol_droid::domain::acp::session::StopReason::EndTurn
);
assert_eq!(chunks_seen, 2);
assert_eq!(assembled_text, "Hello, world!");
Ok(())
}
#[tokio::test]
async fn an_agent_originating_request_round_trips_to_the_consumer() -> Result<(), String> {
use std::path::PathBuf;
let node = ensure_node()?;
let spec = SpawnSpec::new(
node,
vec![reference_agent_path().to_string_lossy().into_owned()],
)
.with_timeout(Duration::from_secs(10));
let peer = AcpStdioPeer::spawn(&spec).map_err(|error| format!("spawning the peer: {error}"))?;
let _: protocol_droid::domain::acp::initialize::InitializeResponse = peer
.send_typed_request(
AcpMethod::Agent(AgentMethod::Initialize),
sample_initialize_request(),
)
.await
.map_err(|error| format!("initialize reply: {error}"))?;
let new_session: protocol_droid::domain::acp::session::NewSessionResponse = peer
.send_typed_request(
AcpMethod::Agent(AgentMethod::SessionNew),
NewSessionRequest {
cwd: "/tmp/example".to_owned(),
additional_directories: None,
mcp_servers: vec![],
},
)
.await
.map_err(|error| format!("session/new reply: {error}"))?;
let session_id = new_session.session_id;
let prompt = PromptRequest {
session_id,
prompt: vec![protocol_droid::domain::acp::content::ContentBlock::Text(
protocol_droid::domain::acp::content::TextContent {
text: "__test_agent_reads_file__".to_owned(),
annotations: None,
meta: std::collections::BTreeMap::new(),
},
)],
};
let prompt_fut = peer.send_typed_request::<_, PromptResponse>(
AcpMethod::Agent(AgentMethod::SessionPrompt),
prompt,
);
tokio::pin!(prompt_fut);
let agent_request_fut = peer.recv_agent_request();
tokio::pin!(agent_request_fut);
let request: protocol_droid::adapters::secondary::acp::AgentRequest = tokio::select! {
biased;
request = &mut agent_request_fut => {
request.ok_or_else(|| "the agent request stream closed".to_owned())?
}
_reply = &mut prompt_fut => {
return Err("the prompt reply arrived before the agent request".to_owned());
}
};
assert_eq!(request.method, "fs/read_text_file");
let path = request
.params
.get("path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "fs/read_text_file request has no `path`".to_owned())?;
let content = std::fs::read_to_string(PathBuf::from(path))
.map_err(|error| format!("reading {path}: {error}"))?;
let reply = serde_json::json!({ "content": content });
peer.send_reply(request.id, reply)
.await
.map_err(|error| format!("send_reply: {error}"))?;
let response: PromptResponse = tokio::time::timeout(Duration::from_secs(2), prompt_fut)
.await
.map_err(|error| format!("waiting for the prompt reply: {error}"))?
.map_err(|error| format!("prompt reply: {error}"))?;
assert_eq!(
response.stop_reason,
protocol_droid::domain::acp::session::StopReason::EndTurn
);
Ok(())
}