use serde_json::{Value, json};
use super::*;
use crate::a2a::testing::{A2aFixture, RpcAnswer, Script, task_json};
use crate::a2a::{A2aAgentConfig, A2aBinding, A2aLimits, remote_output, remote_reply};
use crate::runtime::CancellationToken;
fn fast_limits() -> A2aLimits {
A2aLimits {
poll_interval: Duration::from_millis(10),
max_wait: Duration::from_secs(5),
..A2aLimits::default()
}
}
async fn client_with(fixture: &A2aFixture, limits: A2aLimits) -> Arc<A2aClient> {
let config = A2aAgentConfig::new("nous", fixture.base()).with_limits(limits);
Arc::new(A2aClient::connect(&config).await.expect("connect"))
}
fn send(text: &str) -> A2aRequest {
A2aRequest::send(text)
}
fn working() -> RpcAnswer {
RpcAnswer::Result(task_json("t1", "c1", "TASK_STATE_WORKING"))
}
fn with_artifact(mut task: Value, text: &str) -> Value {
task["artifacts"] = json!([{"artifactId": "a1", "parts": [{"text": text}]}]);
task
}
#[tokio::test]
async fn a_direct_reply_comes_back_as_the_message() {
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Result(json!({"message": {
"messageId": "m1",
"contextId": "c9",
"role": "ROLE_AGENT",
"parts": [{"text": "hello back"}],
}})),
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let outcome = exchange(&client, send("hello"), &RunOptions::default(), &|_| {})
.await
.expect("reply");
let A2aExchange::Reply(message) = outcome else {
panic!("expected a reply, got {outcome:?}");
};
assert_eq!(message.context_id.as_deref(), Some("c9"));
assert_eq!(
remote_reply("nous", &message, 1024),
"--- begin output from remote agent `nous` ---\nhello back\n\
--- end output from remote agent `nous` ---"
);
assert_eq!(fixture.operations(), ["SendMessage"]);
}
#[tokio::test]
async fn a_task_is_polled_until_it_settles() {
let completed = with_artifact(task_json("t1", "c1", "TASK_STATE_COMPLETED"), "42");
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Result(json!({"task": task_json("t1", "c1", "TASK_STATE_SUBMITTED")})),
get: vec![working(), RpcAnswer::Result(completed)],
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let outcome = exchange(&client, send("ask"), &RunOptions::default(), &|_| {})
.await
.expect("settled");
let A2aExchange::Settled(task) = outcome else {
panic!("expected a settled task, got {outcome:?}");
};
assert_eq!(task.status.state, TaskState::Completed);
assert_eq!(task.context_id, "c1");
assert!(remote_output("nous", &task, 1024).contains("\n42\n"));
let sent = &fixture.params_of("SendMessage")[0];
assert_eq!(sent["configuration"]["returnImmediately"], json!(true));
assert_eq!(sent["configuration"]["historyLength"], json!(0));
}
#[tokio::test]
async fn submit_returns_the_task_before_anything_waits_on_it() {
let completed = with_artifact(task_json("t1", "c1", "TASK_STATE_COMPLETED"), "42");
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Result(json!({"task": task_json("t1", "c1", "TASK_STATE_SUBMITTED")})),
get: vec![RpcAnswer::Result(completed)],
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let A2aSubmitted::Task(task) = submit(&client, send("ask")).await.expect("submitted") else {
panic!("expected a task");
};
assert_eq!(task.id, "t1");
assert_eq!(
fixture.operations(),
["SendMessage"],
"nothing has polled yet"
);
let outcome = settle(&client, task, &RunOptions::default(), &|_| {})
.await
.expect("settled");
assert!(matches!(outcome, A2aExchange::Settled(_)), "{outcome:?}");
assert_eq!(fixture.operations(), ["SendMessage", "GetTask"]);
}
#[tokio::test]
async fn a_resume_waits_without_sending() {
let fixture = A2aFixture::start(Script {
get: vec![RpcAnswer::Result(with_artifact(
task_json("t1", "c1", "TASK_STATE_COMPLETED"),
"done",
))],
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let request = A2aRequest::resume("t1");
let outcome = exchange(&client, request, &RunOptions::default(), &|_| {})
.await
.expect("settled");
assert!(matches!(outcome, A2aExchange::Settled(_)));
assert_eq!(fixture.operations(), ["GetTask"]);
}
#[tokio::test]
async fn a_task_still_working_at_the_bound_is_handed_back() {
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Result(json!({"task": task_json("t1", "c1", "TASK_STATE_WORKING")})),
get: vec![working()],
..Script::default()
});
let bound = Duration::from_millis(50);
let client = client_with(
&fixture,
A2aLimits {
max_wait: bound,
..fast_limits()
},
)
.await;
let outcome = exchange(&client, send("long job"), &RunOptions::default(), &|_| {})
.await
.expect("handed back");
let A2aExchange::StillWorking { task, reason } = outcome else {
panic!("expected a working task, got {outcome:?}");
};
assert_eq!(task.id, "t1");
assert_eq!(reason, A2aStillWorking::WaitBound(bound));
assert!(
!fixture.operations().contains(&"CancelTask".to_string()),
"a task handed back is left running"
);
}
#[tokio::test]
async fn a_failed_poll_names_the_task() {
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Result(json!({"task": task_json("t1", "c1", "TASK_STATE_WORKING")})),
get: vec![RpcAnswer::Error(-32001)],
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let error = exchange(&client, send("go"), &RunOptions::default(), &|_| {})
.await
.expect_err("poll failed");
let A2aExchangeError::Poll { task_id, .. } = &error else {
panic!("expected a poll failure, got {error:?}");
};
assert_eq!(task_id, "t1");
assert!(
error.to_string().contains("checking on task t1 failed"),
"{error}"
);
}
#[tokio::test]
async fn a_failed_send_starts_nothing() {
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Error(-32603),
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let error = exchange(&client, send("go"), &RunOptions::default(), &|_| {})
.await
.expect_err("send failed");
assert!(matches!(error, A2aExchangeError::Send(_)), "{error:?}");
assert_eq!(fixture.operations(), ["SendMessage"]);
}
#[tokio::test]
async fn a_cancelled_run_abandons_and_cancels_the_task() {
let fixture = A2aFixture::start(Script {
send: RpcAnswer::Result(json!({"task": task_json("t1", "c1", "TASK_STATE_WORKING")})),
get: vec![working()],
..Script::default()
});
let client = client_with(&fixture, fast_limits()).await;
let token = CancellationToken::default();
let options = RunOptions {
cancellation: Some(token.clone()),
..RunOptions::default()
};
let canceller = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
});
let error = exchange(&client, send("long job"), &options, &|_| {})
.await
.expect_err("cancelled");
canceller.await.expect("canceller");
let A2aExchangeError::Abandoned { task_id, canceled } = &error else {
panic!("expected an abandoned task, got {error:?}");
};
assert_eq!(task_id, "t1");
assert!(canceled.is_ok());
assert!(error.to_string().contains("asked to cancel"), "{error}");
assert_eq!(fixture.params_of("CancelTask")[0]["id"], json!("t1"));
}
#[test]
fn settled_means_terminal_or_waiting_on_the_caller() {
assert!(is_settled(&TaskState::Completed));
assert!(is_settled(&TaskState::InputRequired));
assert!(is_settled(&TaskState::AuthRequired));
assert!(!is_settled(&TaskState::Working));
assert!(!is_settled(&TaskState::Submitted));
}
#[tokio::test]
async fn a_message_carries_its_metadata_and_activates_its_extensions() {
const HOPS: &str = "https://example.com/a2a/hops/v1";
const TRACE: &str = "https://example.com/a2a/trace/v1";
for binding in [A2aBinding::JsonRpc, A2aBinding::HttpJson] {
let fixture = A2aFixture::start(Script::over(binding));
let client = client_with(&fixture, fast_limits()).await;
let request = A2aSend::new("ask")
.with_context_id("c1")
.with_metadata(HOPS, json!({"hop": 2}))
.with_extension(HOPS)
.with_extension(TRACE)
.with_extension(HOPS);
exchange(&client, request.into(), &RunOptions::default(), &|_| {})
.await
.expect("settled");
let sent = &fixture.params_of("SendMessage")[0]["message"];
assert_eq!(sent["metadata"][HOPS], json!({"hop": 2}));
assert_eq!(sent["extensions"], json!([HOPS, TRACE]));
assert_eq!(sent["contextId"], json!("c1"));
let requests = fixture.requests();
assert_eq!(
requests[1].header("a2a-extensions"),
Some(format!("{HOPS},{TRACE}").as_str()),
"{binding}"
);
assert!(
requests[2..]
.iter()
.all(|request| request.header("a2a-extensions").is_none())
);
}
}
#[tokio::test]
async fn a_message_without_extensions_activates_none() {
let fixture = A2aFixture::start(Script::default());
let client = client_with(&fixture, fast_limits()).await;
exchange(&client, send("ask"), &RunOptions::default(), &|_| {})
.await
.expect("settled");
let sent = &fixture.params_of("SendMessage")[0]["message"];
assert_eq!(sent.get("metadata"), None);
assert_eq!(sent.get("extensions"), None);
assert_eq!(fixture.requests()[1].header("a2a-extensions"), None);
}
#[tokio::test]
async fn an_extension_uri_that_would_change_the_list_is_refused_before_sending() {
let fixture = A2aFixture::start(Script::default());
let client = client_with(&fixture, fast_limits()).await;
for uri in [
"",
"https://a.example/x,https://b.example/y",
"https://a.example/ x",
] {
let request = A2aSend::new("ask").with_extension(uri);
let error = submit(&client, request.into()).await.expect_err("refused");
assert!(
matches!(error, A2aExchangeError::Send(A2aError::InvalidExtensionUri)),
"{uri:?}: {error}"
);
}
assert!(fixture.operations().is_empty(), "nothing was sent");
}