use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use aion_core::{
ActivityEvent, ActivityEventKind, ActivityId, InterventionCommand, InterventionKind,
InterventionPrimitive, MessageRole, ProgressDetail, RunId, StopKind, WorkflowId,
};
use aion_integrations::contract::AgentSession;
use aion_integrations::jsonrpc::{
IncomingMessage, JsonRpcConnection, JsonRpcId, JsonRpcNotification, JsonRpcRequest,
JsonRpcResponse,
};
use aion_integrations::{ContentType, HarnessError, HarnessWorkspace};
use chrono::Utc;
use futures::StreamExt;
use serde_json::{Value, json};
use tokio::io::{
AsyncRead, AsyncWrite, AsyncWriteExt, DuplexStream, ReadHalf, Sink, WriteHalf, duplex, split,
};
use uuid::Uuid;
use crate::harness::AcpHarness;
use crate::permission::PermissionPolicy;
use crate::protocol;
use crate::session::{AcpSession, SessionStart};
use crate::translate::EventIdentity;
const DUPLEX_CAPACITY: usize = 1 << 16;
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
const EXIT_GRACE: Duration = Duration::from_secs(2);
const SESSION: &str = "sess-under-test";
const WORKSPACE: &str = "/srv/workspace";
type Wire = JsonRpcConnection<ReadHalf<DuplexStream>, WriteHalf<DuplexStream>>;
type Session = AcpSession<ReadHalf<DuplexStream>, WriteHalf<DuplexStream>>;
type TestResult<T = ()> = Result<T, String>;
fn fail(context: &str, error: &impl std::fmt::Display) -> String {
format!("{context}: {error}")
}
fn identity() -> EventIdentity {
EventIdentity {
workflow_id: WorkflowId::new(Uuid::nil()),
run_id: aion_core::RunId::new(Uuid::from_u128(0x11)),
activity_id: ActivityId::from_sequence_position(1),
attempt: 1,
}
}
fn harness(policy: PermissionPolicy) -> AcpHarness {
harness_with_mcp_servers(policy, Vec::new())
}
fn harness_with_mcp_servers(
policy: PermissionPolicy,
servers: Vec<crate::mcp::McpServerSpec>,
) -> AcpHarness {
AcpHarness::new(
"/nonexistent/acp-agent",
["acp"],
HarnessWorkspace::Fixed(PathBuf::from(WORKSPACE)),
policy,
EXIT_GRACE,
)
.with_mcp_servers(servers)
}
async fn within<T>(what: &str, future: impl Future<Output = T>) -> TestResult<T> {
tokio::time::timeout(TEST_TIMEOUT, future)
.await
.map_err(|_elapsed| format!("{what} did not finish within {TEST_TIMEOUT:?} — it hung"))
}
struct Wired {
session: Session,
agent: Arc<Wire>,
prompt_id: JsonRpcId,
}
async fn agent_opens(agent: &Wire, protocol_version: u16) -> Result<(), HarnessError> {
agent_opens_expecting_mcp(agent, protocol_version, &json!([]), false).await
}
async fn agent_opens_expecting_mcp(
agent: &Wire,
protocol_version: u16,
expected_mcp: &Value,
http_mcp: bool,
) -> Result<(), HarnessError> {
let initialize = expect_request(agent, protocol::METHOD_INITIALIZE).await?;
agent
.send_response(&JsonRpcResponse::success(
initialize.id,
json!({
"protocolVersion": protocol_version,
"agentCapabilities": {
"loadSession": false,
"mcpCapabilities": { "http": http_mcp }
},
"authMethods": [],
"agentInfo": { "name": "fake-acp-agent", "version": "0.0.0" }
}),
))
.await?;
if u32::from(protocol_version) != u32::from(protocol::PROTOCOL_VERSION.as_u16()) {
return Ok(());
}
let new_session = expect_request(agent, protocol::METHOD_SESSION_NEW).await?;
verify_new_session_params(new_session.params.as_ref(), expected_mcp)?;
agent
.send_response(&JsonRpcResponse::success(
new_session.id,
json!({ "sessionId": SESSION }),
))
.await?;
let prompt = expect_request(agent, protocol::METHOD_SESSION_PROMPT).await?;
verify_prompt_params(prompt.params.as_ref())?;
Ok(())
}
fn verify_new_session_params(
params: Option<&Value>,
expected_mcp: &Value,
) -> Result<(), HarnessError> {
let params = params.ok_or_else(|| HarnessError::protocol("session/new carried no params"))?;
if params["cwd"] != json!("/srv/workspace") {
return Err(HarnessError::protocol(format!(
"session/new must send the configured absolute cwd, got {}",
params["cwd"]
)));
}
if params.get("mcpServers") != Some(expected_mcp) {
return Err(HarnessError::protocol(format!(
"session/new must send exactly the configured `mcpServers` ({expected_mcp}), got {:?}",
params.get("mcpServers")
)));
}
Ok(())
}
fn verify_prompt_params(params: Option<&Value>) -> Result<(), HarnessError> {
let params =
params.ok_or_else(|| HarnessError::protocol("session/prompt carried no params"))?;
if params["sessionId"] != json!(SESSION) {
return Err(HarnessError::protocol(format!(
"the prompt must be scoped to the minted session, got {}",
params["sessionId"]
)));
}
if params["prompt"][0]["type"] != json!("text") {
return Err(HarnessError::protocol(format!(
"the prompt must be a `text` content block, got {}",
params["prompt"][0]
)));
}
Ok(())
}
async fn expect_request<R, W>(
wire: &JsonRpcConnection<R, W>,
method: &str,
) -> Result<JsonRpcRequest, HarnessError>
where
R: AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
let message = wire.recv().await?.ok_or_else(|| {
HarnessError::transport(format!("the channel closed while awaiting `{method}`"))
})?;
match message {
IncomingMessage::Request(request) if request.method == method => Ok(request),
IncomingMessage::Request(other) => Err(HarnessError::protocol(format!(
"expected `{method}`, got the request `{}`",
other.method
))),
IncomingMessage::Notification(note) => Err(HarnessError::protocol(format!(
"expected `{method}`, got the notification `{}`",
note.method
))),
IncomingMessage::Response(_) => Err(HarnessError::protocol(format!(
"expected `{method}`, got a response"
))),
}
}
async fn wire_up(policy: PermissionPolicy) -> TestResult<Wired> {
wire_up_with_capacity(policy, DUPLEX_CAPACITY).await
}
async fn wire_up_with_capacity(policy: PermissionPolicy, capacity: usize) -> TestResult<Wired> {
let (client_io, agent_io) = duplex(capacity);
let (client_read, client_write) = split(client_io);
let (agent_read, agent_write) = split(agent_io);
let connection = Arc::new(JsonRpcConnection::new(client_read, client_write));
let agent = Arc::new(JsonRpcConnection::new(agent_read, agent_write));
let script = {
let agent = Arc::clone(&agent);
tokio::spawn(async move {
agent_opens(agent.as_ref(), protocol::PROTOCOL_VERSION.as_u16()).await
})
};
let opened = harness(policy)
.connect(
connection.as_ref(),
PathBuf::from(WORKSPACE),
"do the thing".to_owned(),
None,
)
.await
.map_err(|error| fail("the handshake must succeed", &error))?;
script
.await
.map_err(|error| fail("the agent script task must not panic", &error))?
.map_err(|error| fail("the agent script must complete", &error))?;
let prompt_id = opened.prompt_id.clone();
let session = AcpSession::start(SessionStart {
connection,
session_id: opened.session_id,
prompt_id: opened.prompt_id,
policy,
identity: identity(),
deferred: opened.deferred,
load_replay: opened.load_replay,
config_options: opened.config_options,
child: None,
exit_grace: EXIT_GRACE,
load_session: false,
process_group: None,
});
Ok(Wired {
session,
agent,
prompt_id,
})
}
fn session_update(update: &Value) -> JsonRpcNotification {
JsonRpcNotification::new(
protocol::METHOD_SESSION_UPDATE,
Some(json!({ "sessionId": SESSION, "update": update })),
)
}
fn prompt_response(prompt_id: JsonRpcId, stop_reason: &str) -> JsonRpcResponse {
JsonRpcResponse::success(prompt_id, json!({ "stopReason": stop_reason }))
}
async fn events_for(update: &Value) -> TestResult<Vec<ActivityEvent>> {
let Wired {
mut session,
agent,
prompt_id,
} = wire_up(PermissionPolicy::Deny).await?;
agent
.send_notification(&session_update(update))
.await
.map_err(|error| fail("the update must be writable", &error))?;
agent
.send_response(&prompt_response(prompt_id, "end_turn"))
.await
.map_err(|error| fail("the terminal response must be writable", &error))?;
let events = session.events();
let collected = within(
"draining the transcript to end-of-stream",
events.collect::<Vec<_>>(),
)
.await?;
drop(session);
drop(agent);
Ok(collected)
}
fn has_raw(events: &[ActivityEvent], source: &str) -> bool {
events.iter().any(
|event| matches!(&event.kind, ActivityEventKind::Raw { source: got, .. } if got == source),
)
}
async fn close_turn(
session: Session,
agent: &Wire,
prompt_id: JsonRpcId,
stop_reason: &str,
) -> TestResult {
agent
.send_response(&prompt_response(prompt_id, stop_reason))
.await
.map_err(|error| fail("the terminal response must be writable", &error))?;
let outcome = session.wait_result().await;
match (stop_reason, outcome) {
("end_turn", Ok(payload)) => {
assert_eq!(payload.content_type(), &ContentType::Json);
Ok(())
}
("end_turn", Err(error)) => Err(fail("end_turn is a completed run", &error)),
(_, Err(_)) => Ok(()),
(reason, Ok(payload)) => Err(format!(
"`{reason}` must not be reported as a completed result, got {payload:?}"
)),
}
}
fn cancel_command() -> InterventionCommand {
InterventionCommand {
workflow_id: WorkflowId::new(Uuid::nil()),
run_id: RunId::new_v4(),
activity_id: ActivityId::from_sequence_position(1),
attempt: 1,
issued_by: Some("operator".to_owned()),
issued_at: Utc::now(),
kind: InterventionKind::Cancel {
reason: "the operator stopped it".to_owned(),
},
}
}
fn permission_request(id: JsonRpcId) -> JsonRpcRequest {
JsonRpcRequest::new(
id,
protocol::METHOD_SESSION_REQUEST_PERMISSION,
Some(json!({
"sessionId": SESSION,
"toolCall": { "toolCallId": "t1", "title": "rm -rf /", "kind": "execute" },
"options": [
{ "optionId": "allow-1", "name": "Allow once", "kind": "allow_once" },
{ "optionId": "allow-2", "name": "Always allow", "kind": "allow_always" },
{ "optionId": "deny-1", "name": "Reject", "kind": "reject_once" }
]
})),
)
}
mod handshake_and_streams;
mod outcomes_and_permissions;
mod raw_and_resume;
mod terminal_and_pump;
mod turns_and_mcp;