use std::sync::{Arc, Mutex};
use murmur_artifact::{ConversationMode, NetworkAuthentication, TaskAcceptance};
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot, watch};
use crate::a2a::{
read_send_params, A2aError, A2aTask, CancelOutcome, IncomingMessage, IncomingTask,
JsonRpcRequest, JsonRpcResponse, ListCursor, MurmurError, Part, Role, StateFilter, TaskFilter,
TaskRegistry, TaskState, TaskStatus, DATA_MEDIA_TYPE, INTERNAL_ERROR, INVALID_PARAMS,
METHOD_NOT_FOUND, TEXT_MEDIA_TYPE,
};
use crate::cancel::{Arrival, LiveDelegations, Residue};
use crate::control_plane::{handle_control_request, is_control_path, ControlPlane, ControlRequest};
use crate::delegation::{COMPLETION_SESSION_HEADER, DELEGATION_ID_HEADER};
use crate::detached::DetachedRegistry;
use crate::errors::RuntimeError;
use crate::origin::{self, TaskOrigin, TaskProvenance, PEER_ORIGIN_HEADER, PEER_TRUST_HEADER};
use crate::peer_handoff::{handle_peer_request, is_peer_path, PeerPlane, AUDIENCE_HEADER};
use crate::peer_tasks;
use crate::resource_plane::{
handle_resource_request, reason_phrase, ResourcePlane, ResourceResponse, RESOURCE_PATH_PREFIX,
};
pub(crate) use crate::streaming::STREAM_EXTENSION_URI;
use crate::streaming::{
ending_results, message_carrier, sse_event, status_carrier, task_result, MurmurFrame,
ReplayResult, SseBroadcast, SseEventBuffer, StreamEnd, StreamEvent, StreamFrame, StreamTask,
SSE_HEARTBEAT_COMMENT, SSE_HEARTBEAT_INTERVAL,
};
use crate::types::{CapabilityPolicy, InstalledArtifactSummary};
#[derive(Debug, Clone)]
pub(crate) struct CapsuleIdentity {
pub capsule_name: String,
pub capsule_version: String,
pub session_id: String,
pub capsule_url: String,
}
pub(crate) async fn bind_local_port(
addr: &str,
internal_port: Option<u16>,
) -> Result<(TcpListener, u16), RuntimeError> {
let port = internal_port.unwrap_or(0);
match TcpListener::bind(format!("{addr}:{port}")).await {
Ok(listener) => {
let bound_port = listener
.local_addr()
.map_err(|e| RuntimeError::Runtime(format!("failed to read bound port: {e}")))?
.port();
Ok((listener, bound_port))
}
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && internal_port.is_some() => {
Err(RuntimeError::PortInUse {
port: internal_port.unwrap(),
})
}
Err(e) => Err(RuntimeError::Runtime(format!(
"failed to bind agent-card port: {e}"
))),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DoorMethod {
SendMessage,
SendStreamingMessage,
WatchSession,
GetTask,
ListTasks,
CancelTask,
SubscribeToTask,
StopSession,
GetExtendedAgentCard,
}
impl DoorMethod {
pub(crate) const ALL: [DoorMethod; 9] = [
DoorMethod::SendMessage,
DoorMethod::SendStreamingMessage,
DoorMethod::WatchSession,
DoorMethod::GetTask,
DoorMethod::ListTasks,
DoorMethod::CancelTask,
DoorMethod::SubscribeToTask,
DoorMethod::StopSession,
DoorMethod::GetExtendedAgentCard,
];
pub(crate) fn wire_name(self) -> &'static str {
match self {
DoorMethod::SendMessage => "SendMessage",
DoorMethod::SendStreamingMessage => "SendStreamingMessage",
DoorMethod::WatchSession => "murmur.WatchSession",
DoorMethod::GetTask => "GetTask",
DoorMethod::ListTasks => "ListTasks",
DoorMethod::CancelTask => "CancelTask",
DoorMethod::SubscribeToTask => "SubscribeToTask",
DoorMethod::StopSession => "murmur.StopSession",
DoorMethod::GetExtendedAgentCard => "GetExtendedAgentCard",
}
}
pub(crate) fn scope(self) -> Option<&'static str> {
match self {
DoorMethod::GetExtendedAgentCard => None,
method => Some(method.wire_name()),
}
}
pub(crate) fn resolve(
method: &str,
acceptance: &TaskAcceptance,
authenticated: bool,
) -> Option<DoorMethod> {
let resolved = Self::ALL.into_iter().find(|m| m.wire_name() == method)?;
match (resolved, acceptance) {
(
DoorMethod::SendMessage
| DoorMethod::SendStreamingMessage
| DoorMethod::SubscribeToTask,
TaskAcceptance::None,
) => None,
(DoorMethod::GetExtendedAgentCard, _) if !authenticated => None,
_ => Some(resolved),
}
}
}
pub(crate) const PUSH_CONFIG_METHODS: [&str; 4] = [
"CreateTaskPushNotificationConfig",
"GetTaskPushNotificationConfig",
"ListTaskPushNotificationConfigs",
"DeleteTaskPushNotificationConfig",
];
pub(crate) fn served_methods(
acceptance: &TaskAcceptance,
authenticated: bool,
) -> Vec<&'static str> {
DoorMethod::ALL
.into_iter()
.filter(|m| DoorMethod::resolve(m.wire_name(), acceptance, authenticated).is_some())
.map(DoorMethod::wire_name)
.collect()
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct DeclaredPlanes {
pub files: bool,
pub peer_files: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TransportKind {
Http,
Process,
}
impl TransportKind {
pub(crate) const ALL: [TransportKind; 2] = [TransportKind::Http, TransportKind::Process];
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct TransportCapabilities {
pub kind: TransportKind,
}
pub(crate) fn writes_frame(
method: DoorMethod,
frame: StreamFrame,
transport: TransportKind,
) -> bool {
let (on_task_stream, on_watch_session, transports): (bool, bool, &[TransportKind]) = match frame
{
StreamFrame::Text
| StreamFrame::Thinking
| StreamFrame::ToolCallStarted
| StreamFrame::ToolCallProgress
| StreamFrame::Artifact
| StreamFrame::Gap
| StreamFrame::Lagged => (true, true, &TransportKind::ALL),
StreamFrame::ConnectionAck | StreamFrame::CapsuleClosed => {
(false, true, &TransportKind::ALL)
}
};
let carried = match method {
DoorMethod::SendStreamingMessage | DoorMethod::SubscribeToTask => on_task_stream,
DoorMethod::WatchSession => on_watch_session,
DoorMethod::SendMessage
| DoorMethod::GetTask
| DoorMethod::ListTasks
| DoorMethod::CancelTask
| DoorMethod::StopSession
| DoorMethod::GetExtendedAgentCard => false,
};
carried && transports.contains(&transport)
}
pub(crate) fn served_frames(
acceptance: &TaskAcceptance,
transport: TransportCapabilities,
) -> Vec<&'static str> {
let methods: Vec<DoorMethod> = DoorMethod::ALL
.into_iter()
.filter(|m| DoorMethod::resolve(m.wire_name(), acceptance, false).is_some())
.collect();
StreamFrame::ALL
.into_iter()
.filter(|&frame| {
methods
.iter()
.any(|&method| writes_frame(method, frame, transport.kind))
})
.map(StreamFrame::wire_name)
.collect()
}
pub(crate) struct DoorGate {
pub auth: Arc<crate::door_auth::DoorAuth>,
pub realm: String,
pub extended_card: Value,
}
pub(crate) const DOOR_EXTENSION_URI: &str =
murmur_artifact::docs_reference_url!("agent-card/#murmur-door-v1");
pub(crate) const CAPSULE_EXTENSION_URI: &str =
murmur_artifact::docs_reference_url!("agent-card/#murmur-capsule-v1");
const DOOR_EXTENSION_DESCRIPTION: &str = "Every JSON-RPC method this door answers, A2A methods and murmur's own alike, the murmur ones named murmur.<Method>; whether it accepts tasks from peer capsules; and the forgetSession field a message's metadata may carry under this URI.";
const STREAM_EXTENSION_DESCRIPTION: &str = "The murmur frames this capsule writes in addition to the A2A v1.0 events: sent on SendStreamingMessage and SubscribeToTask when the request's A2A-Extensions header names this URI, and always on murmur.WatchSession. Each is carried in a StreamResponse under metadata keyed by this URI.";
pub(crate) const TASK_SKILL_ID: &str = "task";
pub(crate) const INTERFACE_PROTOCOL_VERSION: &str = crate::a2a::A2A_PROTOCOL_VERSION;
const JSONRPC_BINDING: &str = "JSONRPC";
const MEDIA_MODES: [&str; 2] = [TEXT_MEDIA_TYPE, DATA_MEDIA_TYPE];
pub(crate) fn build_agent_card(
identity: &CapsuleIdentity,
installed_artifacts: &[InstalledArtifactSummary],
capability_policy: &CapabilityPolicy,
task_acceptance: &TaskAcceptance,
planes: DeclaredPlanes,
accepts_peer_tasks: bool,
transport: TransportCapabilities,
) -> serde_json::Value {
let tools: Vec<&str> = installed_artifacts
.iter()
.filter(|a| a.runtime.is_llm_visible())
.map(|a| a.name.as_str())
.collect();
let methods = served_methods(task_acceptance, false);
let streaming = methods.contains(&DoorMethod::SendStreamingMessage.wire_name());
let declared_planes: Vec<&str> = [(planes.files, "files"), (planes.peer_files, "peer_files")]
.into_iter()
.filter_map(|(declared, name)| declared.then_some(name))
.collect();
let skills: Vec<Value> = if methods.contains(&DoorMethod::SendMessage.wire_name()) {
vec![serde_json::json!({
"id": TASK_SKILL_ID,
"name": "Run a task",
"description": "Runs one task given as a text message and reports its outcome.",
"tags": [TASK_SKILL_ID],
})]
} else {
Vec::new()
};
serde_json::json!({
"name": identity.capsule_name,
"description": format!(
"Murmur capsule {} {}",
identity.capsule_name, identity.capsule_version
),
"version": identity.capsule_version,
"supportedInterfaces": [{
"url": interface_url(&identity.capsule_url),
"protocolBinding": JSONRPC_BINDING,
"protocolVersion": INTERFACE_PROTOCOL_VERSION,
}],
"capabilities": {
"streaming": streaming,
"pushNotifications": false,
"extendedAgentCard": false,
"extensions": [
{
"uri": DOOR_EXTENSION_URI,
"description": DOOR_EXTENSION_DESCRIPTION,
"required": false,
"params": { "methods": methods, "peerTasks": accepts_peer_tasks },
},
{
"uri": CAPSULE_EXTENSION_URI,
"description": "The session answering this address and what the capsule may do. Served only to authenticated callers once the door authenticates.",
"required": false,
"params": {
"sessionId": identity.session_id,
"tools": tools,
"shell": !capability_policy.shell_allow.is_empty(),
"network": !capability_policy.network_allow.is_empty(),
"planes": declared_planes,
},
},
{
"uri": STREAM_EXTENSION_URI,
"description": STREAM_EXTENSION_DESCRIPTION,
"required": false,
"params": { "frames": served_frames(task_acceptance, transport) },
},
],
},
"securitySchemes": {},
"securityRequirements": [],
"defaultInputModes": MEDIA_MODES,
"defaultOutputModes": MEDIA_MODES,
"skills": skills,
})
}
fn interface_url(capsule_url: &str) -> String {
let url = capsule_url.trim_end_matches('/');
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else {
format!("http://{url}")
}
}
pub(crate) const BEARER_SCHEME_NAME: &str = "bearer";
const BEARER_SCHEME_DESCRIPTION: &str =
"A token this capsule's runtime mints at launch and accepts until the session ends.";
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AgentCards {
pub public: Value,
pub extended: Option<Value>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_agent_cards(
identity: &CapsuleIdentity,
installed_artifacts: &[InstalledArtifactSummary],
capability_policy: &CapabilityPolicy,
task_acceptance: &TaskAcceptance,
planes: DeclaredPlanes,
accepts_peer_tasks: bool,
transport: TransportCapabilities,
authentication: Option<&NetworkAuthentication>,
) -> AgentCards {
let card = build_agent_card(
identity,
installed_artifacts,
capability_policy,
task_acceptance,
planes,
accepts_peer_tasks,
transport,
);
if authentication.is_none() {
return AgentCards {
public: card,
extended: None,
};
}
let mut card = authenticated_card(card, task_acceptance);
let extended = card.clone();
if let Some(extensions) = card["capabilities"]["extensions"].as_array_mut() {
extensions.retain(|extension| extension["uri"] != CAPSULE_EXTENSION_URI);
}
AgentCards {
public: card,
extended: Some(extended),
}
}
fn authenticated_card(mut card: Value, task_acceptance: &TaskAcceptance) -> Value {
let methods = served_methods(task_acceptance, true);
card["capabilities"]["extendedAgentCard"] = Value::Bool(true);
card["securitySchemes"] = serde_json::json!({
BEARER_SCHEME_NAME: {
"httpAuthSecurityScheme": {
"scheme": "Bearer",
"description": BEARER_SCHEME_DESCRIPTION,
},
},
});
card["securityRequirements"] = serde_json::json!([bearer_requirement(&[])]);
if let Some(extensions) = card["capabilities"]["extensions"].as_array_mut() {
for extension in extensions.iter_mut() {
if extension["uri"] == DOOR_EXTENSION_URI {
extension["params"]["methods"] = serde_json::json!(methods);
}
}
}
let task_requirements: Vec<Value> = [DoorMethod::SendMessage, DoorMethod::SendStreamingMessage]
.into_iter()
.map(DoorMethod::wire_name)
.filter(|method| methods.contains(method))
.map(|method| bearer_requirement(&[method]))
.collect();
if let Some(skills) = card["skills"].as_array_mut() {
for skill in skills.iter_mut() {
if skill["id"] == TASK_SKILL_ID {
skill["securityRequirements"] = Value::Array(task_requirements.clone());
}
}
}
card
}
fn bearer_requirement(scopes: &[&str]) -> Value {
serde_json::json!({"schemes": {BEARER_SCHEME_NAME: {"list": scopes}}})
}
pub(crate) fn extension_params<'a>(
card: &'a Value,
uri: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
card.get("capabilities")?
.get("extensions")?
.as_array()?
.iter()
.find(|extension| extension.get("uri").and_then(Value::as_str) == Some(uri))?
.get("params")?
.as_object()
}
pub(crate) fn session_id_from_card(card: &Value) -> Option<&str> {
extension_params(card, CAPSULE_EXTENSION_URI)?
.get("sessionId")?
.as_str()
.filter(|session_id| !session_id.is_empty())
}
pub(crate) fn jsonrpc_interface_url(card: &Value) -> Option<&str> {
card.get("supportedInterfaces")?
.as_array()?
.iter()
.find(|interface| {
interface.get("protocolBinding").and_then(Value::as_str) == Some(JSONRPC_BINDING)
})?
.get("url")?
.as_str()
.filter(|url| !url.is_empty())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn serve_http(
listener: TcpListener,
mut shutdown_rx: oneshot::Receiver<()>,
card_json: String,
task_registry: Arc<Mutex<TaskRegistry>>,
task_tx: mpsc::Sender<IncomingTask>,
task_acceptance: TaskAcceptance,
sse_tx: SseBroadcast,
sse_buffer: Arc<Mutex<SseEventBuffer>>,
conversation_mode: ConversationMode,
resource_plane: Arc<ResourcePlane>,
peer_plane: Arc<PeerPlane>,
control_plane: Arc<ControlPlane>,
session_id: String,
detached: Option<Arc<DetachedRegistry>>,
live_delegations: Arc<LiveDelegations>,
forgettable_session: bool,
accepts_peer_tasks: bool,
gate: Option<Arc<DoorGate>>,
) {
let conversation_mode_str = match conversation_mode {
ConversationMode::Stateless => "stateless",
ConversationMode::Threaded => "threaded",
};
let mut connections = tokio::task::JoinSet::new();
let (closing_tx, closing_rx) = watch::channel(false);
let accept_failed = loop {
tokio::select! {
_ = &mut shutdown_rx => break false,
Some(_) = connections.join_next(), if !connections.is_empty() => {}
result = listener.accept() => {
match result {
Ok((stream, peer_addr)) => {
let card = card_json.clone();
let registry = Arc::clone(&task_registry);
let tx = task_tx.clone();
let acceptance = task_acceptance.clone();
let sse = sse_tx.clone();
let buf = Arc::clone(&sse_buffer);
let mode_str = conversation_mode_str.to_string();
let plane = Arc::clone(&resource_plane);
let peer = Arc::clone(&peer_plane);
let control = Arc::clone(&control_plane);
let session = session_id.clone();
let detached_for_conn = detached.clone();
let live = Arc::clone(&live_delegations);
let closing = closing_rx.clone();
let gate_for_conn = gate.clone();
connections.spawn(async move {
handle_connection(stream, peer_addr, card, registry, tx, acceptance, sse, buf, mode_str, plane, peer, control, session, detached_for_conn, live, forgettable_session, accepts_peer_tasks, gate_for_conn, closing).await;
});
}
Err(e) => {
crate::runtime_err!("[capsule-runtime] HTTP accept error: {e}");
break true;
}
}
}
}
};
drop(listener);
if accept_failed {
let _ = shutdown_rx.await;
}
let _ = closing_tx.send(true);
let drained = async { while connections.join_next().await.is_some() {} };
let _ = tokio::time::timeout(CONNECTION_DRAIN_GRACE, drained).await;
connections.shutdown().await;
}
#[cfg(test)]
pub(crate) async fn serve_test_door(
accepts_peer_tasks: bool,
) -> (String, oneshot::Sender<()>, mpsc::Receiver<IncomingTask>) {
serve_test_door_with(
TaskAcceptance::Queue,
Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue))),
Arc::new(LiveDelegations::new()),
accepts_peer_tasks,
)
.await
}
#[cfg(test)]
pub(crate) async fn serve_test_door_with(
acceptance: TaskAcceptance,
task_registry: Arc<Mutex<TaskRegistry>>,
live_delegations: Arc<LiveDelegations>,
accepts_peer_tasks: bool,
) -> (String, oneshot::Sender<()>, mpsc::Receiver<IncomingTask>) {
use std::sync::atomic::AtomicU64;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("should bind an ephemeral port");
let addr = listener.local_addr().unwrap().to_string();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let (task_tx, task_rx) = mpsc::channel(4);
let (sse_tx, _) = tokio::sync::broadcast::channel(4);
let session_id = "ses_door".to_string();
let containment = murmur_artifact::ContainmentClass::Advisory;
let nowhere = std::path::Path::new("/nonexistent");
tokio::spawn(serve_http(
listener,
shutdown_rx,
"{}".to_string(),
task_registry,
task_tx,
acceptance,
sse_tx,
Arc::new(Mutex::new(SseEventBuffer::new(8))),
ConversationMode::Stateless,
Arc::new(ResourcePlane::new(
nowhere,
None,
containment,
Arc::new(AtomicU64::new(0)),
None,
)),
Arc::new(PeerPlane::new(
nowhere,
None,
session_id.clone(),
containment,
Arc::new(AtomicU64::new(0)),
None,
)),
Arc::new(ControlPlane::undeclared(session_id.clone())),
session_id,
None,
live_delegations,
false,
accepts_peer_tasks,
None,
));
(addr, shutdown_tx, task_rx)
}
const CONNECTION_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
async fn door_closing(closing: &mut watch::Receiver<bool>) {
let _ = closing.wait_for(|&closing| closing).await;
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: tokio::net::TcpStream,
peer_addr: std::net::SocketAddr,
card_json: String,
task_registry: Arc<Mutex<TaskRegistry>>,
task_tx: mpsc::Sender<IncomingTask>,
task_acceptance: TaskAcceptance,
sse_tx: SseBroadcast,
sse_buffer: Arc<Mutex<SseEventBuffer>>,
conversation_mode_str: String,
resource_plane: Arc<ResourcePlane>,
peer_plane: Arc<PeerPlane>,
control_plane: Arc<ControlPlane>,
session_id: String,
detached: Option<Arc<DetachedRegistry>>,
live_delegations: Arc<LiveDelegations>,
forgettable_session: bool,
accepts_peer_tasks: bool,
gate: Option<Arc<DoorGate>>,
closing: watch::Receiver<bool>,
) {
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
let (reader_half, mut writer_half) = stream.into_split();
let mut reader = BufReader::new(reader_half);
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let mut parts_iter = request_line.split_whitespace();
let method = parts_iter.next().unwrap_or("").to_string();
let path = parts_iter.next().unwrap_or("").to_string();
let mut content_length: usize = 0;
let mut is_json = false;
let mut traceparent: Option<String> = None;
let mut last_event_id: Option<u64> = None;
let mut audience: Option<String> = None;
let mut task_origin: Option<String> = None;
let mut task_trust: Option<String> = None;
let mut delegation_id: Option<String> = None;
let mut completion_session: Option<String> = None;
let mut authorization: Option<String> = None;
let mut authorizations: Vec<String> = Vec::new();
let mut a2a_versions: Vec<String> = Vec::new();
let a2a_version_prefix = format!("{}:", crate::a2a::A2A_VERSION_HEADER.to_ascii_lowercase());
let mut a2a_extensions: Vec<String> = Vec::new();
let a2a_extensions_prefix = format!(
"{}:",
crate::a2a::A2A_EXTENSIONS_HEADER.to_ascii_lowercase()
);
loop {
let mut line = String::new();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if line.trim().is_empty() {
break;
}
let lower = line.to_ascii_lowercase();
let lower = lower.trim_end();
if lower.starts_with("authorization:") {
let value = line.trim_end()["authorization:".len()..].trim().to_string();
authorizations.push(value.clone());
authorization = Some(value);
} else if lower.starts_with(&a2a_version_prefix) {
let value = line.trim_end()[a2a_version_prefix.len()..]
.trim()
.to_string();
a2a_versions.push(value);
} else if lower.starts_with(&a2a_extensions_prefix) {
let value = line.trim_end()[a2a_extensions_prefix.len()..]
.trim()
.to_string();
a2a_extensions.push(value);
} else if let Some(rest) = lower.strip_prefix("content-length:") {
content_length = rest.trim().parse().unwrap_or(0);
} else if lower.starts_with("content-type:") && lower.contains("application/json") {
is_json = true;
} else if let Some(rest) = lower.strip_prefix("traceparent:") {
traceparent = Some(rest.trim().to_string());
} else if let Some(rest) = lower.strip_prefix("last-event-id:") {
last_event_id = rest.trim().parse().ok();
} else if let Some(rest) = lower.strip_prefix(&format!("{AUDIENCE_HEADER}:")) {
audience = Some(rest.trim().to_string());
} else if let Some(rest) = lower.strip_prefix(&format!("{PEER_ORIGIN_HEADER}:")) {
task_origin = Some(rest.trim().to_string());
} else if let Some(rest) = lower.strip_prefix(&format!("{PEER_TRUST_HEADER}:")) {
task_trust = Some(rest.trim().to_string());
} else if let Some(rest) = lower.strip_prefix(&format!("{DELEGATION_ID_HEADER}:")) {
delegation_id = Some(rest.trim().to_string());
} else if let Some(rest) = lower.strip_prefix(&format!("{COMPLETION_SESSION_HEADER}:")) {
completion_session = Some(rest.trim().to_string());
}
}
let provenance = origin::from_wire(task_origin.as_deref(), task_trust.as_deref());
let is_completion = provenance.origin() == TaskOrigin::Completion;
if is_peer_path(&path) {
let response = handle_peer_request(&peer_plane, &method, &path, audience.as_deref()).await;
let _ = writer_half.write_all(&framed_bytes(&response)).await;
return;
}
if is_control_path(&path) {
let request = ControlRequest {
method: &method,
path: &path,
authorization: authorization.as_deref(),
peer: Some(peer_addr.ip()),
content_length,
is_json,
};
let response = handle_control_request(&control_plane, request, &mut reader).await;
let _ = writer_half.write_all(&framed_bytes(&response)).await;
return;
}
if method == "GET" && path == "/.well-known/agent-card.json" {
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
card_json.len(),
card_json,
);
let _ = writer_half.write_all(response.as_bytes()).await;
return;
}
let grant = match gate.as_deref() {
None => None,
Some(gate) => {
let headers: Vec<&str> = authorizations.iter().map(String::as_str).collect();
match gate.auth.verify(&headers) {
Ok(grant) => Some(grant),
Err(refusal) => {
let response = framed_bytes(&refusal.response(&gate.realm));
write_refusal(writer_half, reader, &response).await;
return;
}
}
}
};
let caller_member = grant
.as_ref()
.and_then(|grant| grant.formation_caller())
.map(str::to_string);
let caller = TaskCaller::of(grant.as_ref());
if peer_tasks::refuses(provenance, accepts_peer_tasks) {
write_refusal(writer_half, reader, &framed_bytes(&peer_tasks::refusal())).await;
return;
}
let refuse_scope = |scope: &str| -> Option<Vec<u8>> {
let (gate, grant) = gate.as_deref().zip(grant.as_ref())?;
let refusal = grant.require(scope).err()?;
Some(framed_bytes(&refusal.response(&gate.realm)))
};
if path.starts_with(RESOURCE_PATH_PREFIX) {
if let Some(refused) = refuse_scope(crate::door_auth::RESOURCES_FILES_SCOPE) {
write_refusal(writer_half, reader, &refused).await;
return;
}
let response = handle_resource_request(&resource_plane, &method, &path).await;
let _ = writer_half.write_all(&framed_bytes(&response)).await;
return;
}
if method == "POST" && (path == "/" || path.is_empty()) && is_json && content_length > 0 {
let mut body = vec![0u8; content_length];
if reader.read_exact(&mut body).await.is_err() {
return;
}
let body_str = String::from_utf8_lossy(&body).to_string();
let req = match JsonRpcRequest::from_body(&body_str) {
Ok(req) => req,
Err(refusal) => {
let _ = writer_half
.write_all(refusal.into_http_response().as_bytes())
.await;
return;
}
};
if !crate::a2a::accepts_a2a_version(&a2a_versions) {
let refusal = crate::a2a::version_not_supported(req.id, &a2a_versions);
let _ = writer_half
.write_all(refusal.into_http_response().as_bytes())
.await;
return;
}
if is_completion && completion_session.as_deref() != Some(session_id.as_str()) {
let addressed = completion_session.as_deref().unwrap_or("");
let shown = if addressed.is_empty() {
"<unaddressed>"
} else {
addressed
};
let response = JsonRpcResponse::murmur_error(
req.id,
MurmurError::CompletionMisaddressed,
&format!(
"completion is addressed to session {shown}, which is not the session \
running here"
),
&[("addressedSession", addressed.to_string())],
)
.into_http_response();
let _ = writer_half.write_all(response.as_bytes()).await;
return;
}
if is_completion {
let response = handle_completion(req, delegation_id.as_deref(), &live_delegations);
let _ = writer_half.write_all(response.as_bytes()).await;
return;
}
let Some(resolved) = DoorMethod::resolve(&req.method, &task_acceptance, gate.is_some())
else {
let streams = [
DoorMethod::SendStreamingMessage.wire_name(),
DoorMethod::SubscribeToTask.wire_name(),
];
let response = if PUSH_CONFIG_METHODS.contains(&req.method.as_str()) {
crate::a2a::push_notification_not_supported(req.id)
} else if req.method == DoorMethod::GetExtendedAgentCard.wire_name() {
JsonRpcResponse::a2a_error(
req.id,
A2aError::UnsupportedOperation,
"this agent serves no extended agent card",
&[],
)
} else if streams.contains(&req.method.as_str()) {
JsonRpcResponse::a2a_error(
req.id,
A2aError::UnsupportedOperation,
"this agent does not stream: it starts no task",
&[],
)
} else {
JsonRpcResponse::err(req.id, METHOD_NOT_FOUND, "Method not found")
};
let _ = writer_half
.write_all(response.into_http_response().as_bytes())
.await;
return;
};
if let Some(refused) = resolved.scope().and_then(refuse_scope) {
let _ = writer_half.write_all(&refused).await;
return;
}
if !req.params.is_object() {
let response = JsonRpcResponse::err(
req.id,
INVALID_PARAMS,
"Invalid parameters: params must be an object",
)
.into_http_response();
let _ = writer_half.write_all(response.as_bytes()).await;
return;
}
if let Some(response) = tenant_refusal(&req) {
let _ = writer_half
.write_all(response.into_http_response().as_bytes())
.await;
return;
}
let response = match resolved {
DoorMethod::SendMessage => {
handle_message_send(
writer_half,
req,
&task_registry,
&task_tx,
MessageSender {
traceparent,
provenance,
caller_member,
caller,
},
forgettable_session,
sse_tx,
closing,
)
.await;
return;
}
DoorMethod::SendStreamingMessage => {
handle_message_stream(
writer_half,
req,
&task_registry,
&task_tx,
MessageSender {
traceparent,
provenance,
caller_member,
caller,
},
forgettable_session,
StreamRequest {
last_event_id,
activated: crate::a2a::activates_extension(
&a2a_extensions,
STREAM_EXTENSION_URI,
),
},
sse_tx,
sse_buffer,
closing,
)
.await;
return;
}
DoorMethod::SubscribeToTask => {
handle_task_subscribe(
writer_half,
req,
&task_registry,
&caller,
StreamRequest {
last_event_id,
activated: crate::a2a::activates_extension(
&a2a_extensions,
STREAM_EXTENSION_URI,
),
},
sse_tx,
sse_buffer,
closing,
)
.await;
return;
}
DoorMethod::WatchSession => {
handle_watch_session(
writer_half,
&req.id,
last_event_id,
sse_tx,
sse_buffer,
conversation_mode_str,
closing,
)
.await;
return;
}
DoorMethod::GetExtendedAgentCard => match gate.as_deref() {
Some(gate) => JsonRpcResponse::ok(req.id, &gate.extended_card).into_http_response(),
None => {
unreachable!("resolve serves the extended card only on an authenticated door")
}
},
door_method => handle_jsonrpc(
door_method,
req,
&task_registry,
caller,
detached.as_ref(),
&live_delegations,
&session_id,
),
};
let _ = writer_half.write_all(response.as_bytes()).await;
return;
}
let response =
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_string();
let _ = writer_half.write_all(response.as_bytes()).await;
}
const REFUSAL_DRAIN_LIMIT: usize = 64 * 1024;
const REFUSAL_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
async fn write_refusal<R: tokio::io::AsyncRead + Unpin>(
mut writer: tokio::net::tcp::OwnedWriteHalf,
mut reader: R,
response: &[u8],
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
if writer.write_all(response).await.is_err() {
return;
}
let _ = writer.shutdown().await;
let drain = async {
let mut sink = [0u8; 4096];
let mut left = REFUSAL_DRAIN_LIMIT;
while left > 0 {
match reader.read(&mut sink).await {
Ok(0) | Err(_) => break,
Ok(read) => left = left.saturating_sub(read),
}
}
};
let _ = tokio::time::timeout(REFUSAL_DRAIN_GRACE, drain).await;
}
fn framed_bytes(response: &ResourceResponse) -> Vec<u8> {
let mut head = format!(
"HTTP/1.1 {} {}\r\n",
response.status,
reason_phrase(response.status)
);
for (name, value) in &response.headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("connection: close\r\n\r\n");
let mut bytes = head.into_bytes();
bytes.extend_from_slice(&response.body);
bytes
}
pub(crate) struct StreamRequest {
pub last_event_id: Option<u64>,
pub activated: bool,
}
struct StreamConnection {
writer: tokio::net::tcp::OwnedWriteHalf,
request_id: String,
activated: bool,
last_seen: Option<u64>,
}
impl StreamConnection {
async fn open(
mut writer: tokio::net::tcp::OwnedWriteHalf,
request_id: &Value,
extensions: &[&str],
activated: bool,
) -> Result<Self, ()> {
use tokio::io::AsyncWriteExt;
let echo = crate::a2a::extensions_echo(extensions);
let head = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: keep-alive\r\n{echo}\r\n"
);
writer.write_all(head.as_bytes()).await.map_err(|_| ())?;
Ok(Self {
writer,
request_id: serde_json::to_string(request_id).unwrap_or_else(|_| "null".into()),
activated,
last_seen: None,
})
}
async fn write(&mut self, id: Option<u64>, result: &str) -> Result<(), ()> {
use tokio::io::AsyncWriteExt;
let event = sse_event(id, &self.request_id, result);
self.writer
.write_all(event.as_bytes())
.await
.map_err(|_| ())
}
async fn heartbeat(&mut self) -> Result<(), ()> {
use tokio::io::AsyncWriteExt;
self.writer
.write_all(SSE_HEARTBEAT_COMMENT)
.await
.map_err(|_| ())
}
fn admit(&mut self, id: u64) -> bool {
if self.last_seen.is_some_and(|seen| id <= seen) {
return false;
}
self.last_seen = Some(id);
true
}
}
fn replay(
sse_buffer: &Mutex<SseEventBuffer>,
last_id: u64,
) -> (Option<u64>, Vec<Arc<StreamEvent>>) {
match sse_buffer.lock().unwrap().replay_from(last_id) {
ReplayResult::Complete(events) => (None, events),
ReplayResult::WithGap {
first_available_id,
events,
} => (Some(first_available_id), events),
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_message_stream(
mut writer: tokio::net::tcp::OwnedWriteHalf,
req: JsonRpcRequest,
task_registry: &Arc<Mutex<TaskRegistry>>,
task_tx: &mpsc::Sender<IncomingTask>,
sender: MessageSender,
forgettable_session: bool,
request: StreamRequest,
sse_tx: SseBroadcast,
sse_buffer: Arc<Mutex<SseEventBuffer>>,
closing: watch::Receiver<bool>,
) {
use tokio::io::AsyncWriteExt;
let rx = sse_tx.subscribe();
drop(sse_tx);
let mut extensions = Vec::new();
let routed = match read_message(&req.id, &req.params, forgettable_session) {
Ok(message) => {
if message.door_extension {
extensions.push(DOOR_EXTENSION_URI);
}
route_message(&req.id, message, task_registry, task_tx, sender)
}
Err(response) => MessageRoute::Refused(response),
};
if request.activated {
extensions.push(STREAM_EXTENSION_URI);
}
let (opening, rejected) = match routed {
MessageRoute::Refused(response) => {
let _ = writer
.write_all(response.into_http_response().as_bytes())
.await;
return;
}
MessageRoute::Started(task) | MessageRoute::Continued(task) => (task, false),
MessageRoute::Rejected(task) => (task, true),
};
let Ok(mut connection) =
StreamConnection::open(writer, &req.id, &extensions, request.activated).await
else {
return;
};
if connection
.write(None, &task_result(&opening))
.await
.is_err()
|| rejected
{
return;
}
serve_task_stream(
connection,
&opening,
request.last_event_id,
rx,
task_registry,
&sse_buffer,
closing,
true,
)
.await;
}
#[allow(clippy::too_many_arguments)]
async fn handle_task_subscribe(
mut writer: tokio::net::tcp::OwnedWriteHalf,
req: JsonRpcRequest,
task_registry: &Arc<Mutex<TaskRegistry>>,
caller: &TaskCaller,
request: StreamRequest,
sse_tx: SseBroadcast,
sse_buffer: Arc<Mutex<SseEventBuffer>>,
closing: watch::Receiver<bool>,
) {
use tokio::io::AsyncWriteExt;
let rx = sse_tx.subscribe();
drop(sse_tx);
let opening = match subscribable_task(&req, task_registry, caller) {
Ok(task) => task,
Err(response) => {
let _ = writer
.write_all(response.into_http_response().as_bytes())
.await;
return;
}
};
let extensions: &[&str] = if request.activated {
&[STREAM_EXTENSION_URI]
} else {
&[]
};
let Ok(mut connection) =
StreamConnection::open(writer, &req.id, extensions, request.activated).await
else {
return;
};
if connection
.write(None, &task_result(&opening))
.await
.is_err()
{
return;
}
serve_task_stream(
connection,
&opening,
request.last_event_id,
rx,
task_registry,
&sse_buffer,
closing,
false,
)
.await;
}
fn subscribable_task(
req: &JsonRpcRequest,
task_registry: &Mutex<TaskRegistry>,
caller: &TaskCaller,
) -> Result<A2aTask, JsonRpcResponse> {
let Some(task_id) = req
.params
.get("id")
.and_then(Value::as_str)
.filter(|task_id| !task_id.is_empty())
else {
return Err(JsonRpcResponse::err(
req.id.clone(),
INVALID_PARAMS,
"SubscribeToTask requires an id",
));
};
let reg = task_registry.lock().unwrap();
let Some(task) = reg
.get_task(task_id)
.filter(|_| visible_to(®, task_id, caller))
else {
return Err(task_not_found_response(req.id.clone(), Some(task_id)));
};
if task.status.state.is_terminal() {
let state = task.status.state.wire_name();
return Err(JsonRpcResponse::a2a_error(
req.id.clone(),
A2aError::UnsupportedOperation,
&format!("task {task_id} has ended in {state}; there is nothing left to subscribe to"),
&[
("taskId", task_id.to_string()),
("state", state.to_string()),
],
));
}
Ok(task)
}
#[allow(clippy::too_many_arguments)]
async fn serve_task_stream(
mut connection: StreamConnection,
opening: &A2aTask,
last_event_id: Option<u64>,
mut rx: tokio::sync::broadcast::Receiver<Arc<StreamEvent>>,
task_registry: &Mutex<TaskRegistry>,
sse_buffer: &Mutex<SseEventBuffer>,
mut closing: watch::Receiver<bool>,
ends_on_input_required: bool,
) {
let task = StreamTask::new(&opening.id, &opening.context_id);
let mut state = opening.status.state.clone();
let ends = |end: Option<StreamEnd>| match end {
Some(StreamEnd::Terminal) => true,
Some(StreamEnd::Interrupted) => ends_on_input_required,
None => false,
};
if let Some(last_id) = last_event_id {
let (gap, events) = replay(sse_buffer, last_id);
if let (Some(first_available_id), true) = (gap, connection.activated) {
let frame = MurmurFrame::Gap { first_available_id };
if connection
.write(None, &status_carrier(&task, state.clone(), &frame))
.await
.is_err()
{
return;
}
}
for event in events {
connection.admit(event.id);
if event.task_id != task.task_id {
continue;
}
let Some(result) = event.result(connection.activated) else {
continue;
};
if connection.write(Some(event.id), result).await.is_err() {
return;
}
if let Some(written) = &event.state {
state = written.clone();
}
if event.ends == Some(StreamEnd::Terminal) {
return;
}
}
}
let mut heartbeat = tokio::time::interval(SSE_HEARTBEAT_INTERVAL);
heartbeat.tick().await; let mut lagged = false;
loop {
tokio::select! {
biased;
_ = heartbeat.tick() => {
if connection.heartbeat().await.is_err() {
return;
}
if !lagged {
continue;
}
let Some(results) =
lost_stream_end(task_registry, &task, ends_on_input_required)
else {
continue;
};
for result in results {
if connection.write(None, &result).await.is_err() {
return;
}
}
return;
}
result = rx.recv() => {
match result {
Ok(event) => {
if !connection.admit(event.id) || event.task_id != task.task_id {
continue;
}
let Some(result) = event.result(connection.activated) else {
continue;
};
if connection.write(Some(event.id), result).await.is_err() {
return;
}
if let Some(written) = &event.state {
state = written.clone();
}
if ends(event.ends) {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
crate::runtime_err!("[capsule-runtime] SSE broadcast lagged by {n} events");
lagged = true;
if connection.activated {
let frame = MurmurFrame::Lagged { missed: n };
if connection
.write(None, &status_carrier(&task, state.clone(), &frame))
.await
.is_err()
{
return;
}
}
}
}
}
() = door_closing(&mut closing) => return,
}
}
}
fn lost_stream_end(
task_registry: &Mutex<TaskRegistry>,
task: &StreamTask,
ends_on_input_required: bool,
) -> Option<Vec<String>> {
let held = settled_task(task_registry, &task.task_id)?;
if held.status.state.is_terminal() {
return Some(ending_results(&held));
}
if !ends_on_input_required {
return None;
}
let prompt = held
.artifact(crate::a2a::PROMPT_ARTIFACT)
.and_then(crate::a2a::A2aArtifact::first_text)
.unwrap_or_default();
Some(vec![crate::streaming::input_required_result(task, prompt)])
}
async fn handle_watch_session(
writer: tokio::net::tcp::OwnedWriteHalf,
request_id: &Value,
last_event_id: Option<u64>,
sse_tx: SseBroadcast,
sse_buffer: Arc<Mutex<SseEventBuffer>>,
conversation_mode_str: String,
mut closing: watch::Receiver<bool>,
) {
let mut rx = sse_tx.subscribe();
drop(sse_tx);
let Ok(mut connection) = StreamConnection::open(
writer,
request_id,
&[DOOR_EXTENSION_URI, STREAM_EXTENSION_URI],
true,
)
.await
else {
return;
};
let ack = MurmurFrame::ConnectionAck {
role: "observer",
conversation_mode: conversation_mode_str,
};
if connection
.write(None, &message_carrier(&ack))
.await
.is_err()
{
return;
}
let (gap, events) = replay(&sse_buffer, last_event_id.unwrap_or(0));
if let Some(first_available_id) = gap {
let frame = MurmurFrame::Gap { first_available_id };
if connection
.write(None, &message_carrier(&frame))
.await
.is_err()
{
return;
}
}
for event in events {
connection.admit(event.id);
if connection
.write(Some(event.id), &event.extended)
.await
.is_err()
{
return;
}
}
let mut heartbeat = tokio::time::interval(SSE_HEARTBEAT_INTERVAL);
heartbeat.tick().await;
loop {
tokio::select! {
biased;
_ = heartbeat.tick() => {
if connection.heartbeat().await.is_err() {
return;
}
}
result = rx.recv() => {
match result {
Ok(event) => {
if !connection.admit(event.id) {
continue;
}
if connection.write(Some(event.id), &event.extended).await.is_err() {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
let _ = connection
.write(None, &message_carrier(&MurmurFrame::CapsuleClosed))
.await;
return;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
crate::runtime_err!("[capsule-runtime] murmur.WatchSession: SSE broadcast lagged by {n} events");
let frame = MurmurFrame::Lagged { missed: n };
if connection.write(None, &message_carrier(&frame)).await.is_err() {
return;
}
}
}
}
() = door_closing(&mut closing) => return,
}
}
}
fn handle_completion(
req: JsonRpcRequest,
delegation_id: Option<&str>,
live_delegations: &LiveDelegations,
) -> String {
if req.method != DoorMethod::SendMessage.wire_name() {
return JsonRpcResponse::err(
req.id,
METHOD_NOT_FOUND,
&format!(
"a completion is posted with {}",
DoorMethod::SendMessage.wire_name()
),
)
.into_http_response();
}
let Some(delegation_id) = delegation_id.filter(|id| !id.is_empty()) else {
return JsonRpcResponse::murmur_error(
req.id,
MurmurError::CompletionNotAwaited,
&format!(
"completion names no delegation in {DELEGATION_ID_HEADER}, so no task in this \
session is waiting for it"
),
&[],
)
.into_http_response();
};
let not_awaited = |message: String| {
JsonRpcResponse::murmur_error(
req.id.clone(),
MurmurError::CompletionNotAwaited,
&message,
&[("delegationId", delegation_id.to_string())],
)
.into_http_response()
};
match live_delegations.arrive(delegation_id) {
Arrival::Delivered | Arrival::AlreadyDelivered => JsonRpcResponse::ok(
req.id.clone(),
serde_json::json!({ "message": completion_received(&req.params, delegation_id) }),
)
.into_http_response(),
Arrival::NotOutstanding => not_awaited(format!(
"no task in this session is waiting for delegation {delegation_id}"
)),
Arrival::BeingEnded => not_awaited(format!(
"delegation {delegation_id} is being ended by this session; its launcher records the \
outcome"
)),
}
}
fn completion_received(params: &Value, delegation_id: &str) -> Value {
let mut message = serde_json::json!({
"messageId": format!("msg_{delegation_id}_received"),
"role": Role::Agent,
"parts": [Part::data(serde_json::json!({
"delegation_id": delegation_id,
"received": true,
}))],
});
if let Some(context_id) = params.pointer("/message/contextId").and_then(Value::as_str) {
message["contextId"] = Value::String(context_id.to_string());
}
message
}
fn handle_jsonrpc(
method: DoorMethod,
req: JsonRpcRequest,
task_registry: &Arc<Mutex<TaskRegistry>>,
caller: TaskCaller,
detached: Option<&Arc<DetachedRegistry>>,
live_delegations: &LiveDelegations,
session_id: &str,
) -> String {
let id = req.id;
match method {
DoorMethod::GetTask => handle_tasks_get(id, &req.params, task_registry, &caller),
DoorMethod::ListTasks => handle_tasks_list(id, &req.params, task_registry, &caller),
DoorMethod::CancelTask => handle_tasks_cancel(
id,
&req.params,
task_registry,
&caller,
detached,
live_delegations,
),
DoorMethod::StopSession => {
handle_stop_session(id, task_registry, detached, live_delegations, session_id)
}
DoorMethod::SendMessage
| DoorMethod::SendStreamingMessage
| DoorMethod::SubscribeToTask
| DoorMethod::WatchSession
| DoorMethod::GetExtendedAgentCard => {
unreachable!(
"handle_connection answers SendMessage, the streaming methods and the extended card"
)
}
}
}
pub(crate) struct MessageSender {
pub traceparent: Option<String>,
pub provenance: TaskProvenance,
pub caller_member: Option<String>,
pub caller: TaskCaller,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TaskCaller {
Session,
Credential(String),
}
impl TaskCaller {
pub(crate) fn of(grant: Option<&crate::door_auth::DoorGrant>) -> TaskCaller {
match grant {
Some(grant) if grant.credential() != murmur_artifact::OPERATOR_CREDENTIAL => {
TaskCaller::Credential(grant.credential().to_string())
}
_ => TaskCaller::Session,
}
}
}
#[derive(Debug)]
enum MessageRoute {
Started(A2aTask),
Rejected(A2aTask),
Continued(A2aTask),
Refused(JsonRpcResponse),
}
fn route_message(
id: &Value,
message: IncomingMessage,
task_registry: &Mutex<TaskRegistry>,
task_tx: &mpsc::Sender<IncomingTask>,
sender: MessageSender,
) -> MessageRoute {
let mut reg = task_registry.lock().unwrap();
let caller = &sender.caller;
let mut text = message.agent_text();
if let Some(block) = reg.reference_block(&message.reference_task_ids, |task_id| {
visible_to(®, task_id, caller)
}) {
text = format!("{text}\n\n{block}");
}
let Some(task_id) = message.task_id.clone() else {
return start_task(id, message, text, &mut reg, task_tx, sender);
};
let Some((state, task_context)) = reg
.history
.get(&task_id)
.filter(|_| visible_to(®, &task_id, caller))
.cloned()
else {
return MessageRoute::Refused(task_not_found_response(id.clone(), Some(&task_id)));
};
let unsupported = |message: String| {
MessageRoute::Refused(JsonRpcResponse::a2a_error(
id.clone(),
A2aError::UnsupportedOperation,
&message,
&[
("taskId", task_id.clone()),
("state", state.wire_name().to_string()),
],
))
};
if state.is_terminal() {
return unsupported(format!(
"task {task_id} has ended in {}; send a new message without taskId, in the same \
contextId, to start a new task",
state.wire_name()
));
}
if state != TaskState::InputRequired {
return unsupported(format!(
"task {task_id} is {}; this agent takes a message on a task only while it waits in \
{}",
state.wire_name(),
TaskState::InputRequired.wire_name()
));
}
if let Some(context_id) = message.context_id.as_deref().filter(|c| *c != task_context) {
return MessageRoute::Refused(JsonRpcResponse::err(
id.clone(),
INVALID_PARAMS,
&format!(
"Invalid params: message.contextId {context_id} does not match task {task_id}, \
whose contextId is {task_context}"
),
));
}
if reg.deliver_input(&task_id, text).is_err() {
return unsupported(format!("task {task_id} is no longer waiting for input"));
}
match reg.get_task(&task_id) {
Some(task) => MessageRoute::Continued(task),
None => MessageRoute::Refused(task_not_found_response(id.clone(), Some(&task_id))),
}
}
fn start_task(
id: &Value,
message: IncomingMessage,
text: String,
reg: &mut TaskRegistry,
task_tx: &mpsc::Sender<IncomingTask>,
sender: MessageSender,
) -> MessageRoute {
let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
let context_id = message
.context_id
.clone()
.unwrap_or_else(|| format!("ctx_{}", uuid::Uuid::now_v7().simple()));
if !reg.can_accept() {
let refusal = if reg.is_closed() {
crate::a2a::REJECTED_SESSION_CLOSING_MESSAGE
} else {
crate::a2a::REJECTED_BUSY_MESSAGE
};
let task = A2aTask {
status: TaskStatus::at(
TaskState::Rejected,
Some(crate::a2a::StatusMessage::agent(
&task_id,
&context_id,
refusal,
)),
crate::a2a::status_now(),
),
id: task_id,
context_id,
artifacts: None,
metadata: None,
};
return MessageRoute::Rejected(task);
}
reg.enqueue(&task_id, &context_id);
if let TaskCaller::Credential(credential) = &sender.caller {
reg.record_owner(&task_id, credential);
}
let part_kinds = message.part_kinds();
let incoming = IncomingTask {
task_id: task_id.clone(),
context_id: context_id.clone(),
message_id: message.message_id,
message_text: text,
traceparent: sender.traceparent,
provenance: sender.provenance,
source: crate::a2a::SOURCE_A2A,
forget_session: message.forget_session,
caller_member: sender.caller_member,
reference_task_ids: message.reference_task_ids,
part_kinds,
};
if task_tx.try_send(incoming).is_err() {
reg.withdraw(&task_id);
return MessageRoute::Refused(JsonRpcResponse::err(
id.clone(),
INTERNAL_ERROR,
"internal error: queue send failed",
));
}
MessageRoute::Started(reg.get_task(&task_id).unwrap_or_else(|| A2aTask {
id: task_id,
context_id,
status: TaskStatus::of(TaskState::Submitted),
artifacts: None,
metadata: None,
}))
}
fn read_message(
id: &Value,
params: &Value,
forgettable_session: bool,
) -> Result<IncomingMessage, JsonRpcResponse> {
let message = read_send_params(params).map_err(|refusal| refusal.into_response(id.clone()))?;
if message.forget_session && !forgettable_session {
return Err(JsonRpcResponse::err(
id.clone(),
INVALID_PARAMS,
"forgetSession asks this capsule to forget the harness session this context names, \
and only a capsule on inference.transport: process has one",
));
}
Ok(message)
}
const HELD_SEND_RECHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
enum SendAnswer {
Now(String),
Held(Box<HeldMessage>),
}
struct HeldMessage {
id: Value,
task: A2aTask,
extensions: &'static [&'static str],
}
impl HeldMessage {
fn answer(self, task: &A2aTask) -> String {
JsonRpcResponse::ok(self.id, serde_json::json!({ "task": task }))
.into_http_response_echoing(self.extensions)
}
}
fn accept_message_send(
id: Value,
params: &Value,
task_registry: &Arc<Mutex<TaskRegistry>>,
task_tx: &mpsc::Sender<IncomingTask>,
sender: MessageSender,
forgettable_session: bool,
) -> SendAnswer {
let message = match read_message(&id, params, forgettable_session) {
Ok(message) => message,
Err(response) => return SendAnswer::Now(response.into_http_response()),
};
let extensions: &'static [&'static str] = if message.door_extension {
&[DOOR_EXTENSION_URI]
} else {
&[]
};
let return_immediately = message.return_immediately;
let task = match route_message(&id, message, task_registry, task_tx, sender) {
MessageRoute::Refused(response) => return SendAnswer::Now(response.into_http_response()),
MessageRoute::Rejected(task) => task,
MessageRoute::Started(task) | MessageRoute::Continued(task) if !return_immediately => {
return SendAnswer::Held(Box::new(HeldMessage {
id,
task,
extensions,
}))
}
MessageRoute::Started(task) | MessageRoute::Continued(task) => task,
};
SendAnswer::Now(
JsonRpcResponse::ok(id, serde_json::json!({ "task": task }))
.into_http_response_echoing(extensions),
)
}
#[allow(clippy::too_many_arguments)]
async fn handle_message_send(
mut writer: tokio::net::tcp::OwnedWriteHalf,
req: JsonRpcRequest,
task_registry: &Arc<Mutex<TaskRegistry>>,
task_tx: &mpsc::Sender<IncomingTask>,
sender: MessageSender,
forgettable_session: bool,
sse_tx: SseBroadcast,
closing: watch::Receiver<bool>,
) {
use tokio::io::AsyncWriteExt;
let rx = sse_tx.subscribe();
drop(sse_tx);
let response = match accept_message_send(
req.id,
&req.params,
task_registry,
task_tx,
sender,
forgettable_session,
) {
SendAnswer::Now(response) => response,
SendAnswer::Held(held) => {
let task = hold_until_settled(&held.task, rx, task_registry, closing).await;
held.answer(&task)
}
};
let _ = writer.write_all(response.as_bytes()).await;
}
async fn hold_until_settled(
routed: &A2aTask,
mut rx: tokio::sync::broadcast::Receiver<Arc<StreamEvent>>,
task_registry: &Mutex<TaskRegistry>,
mut closing: watch::Receiver<bool>,
) -> A2aTask {
use tokio::sync::broadcast::error::RecvError;
let task_id = routed.id.as_str();
let mut recheck = tokio::time::interval_at(
tokio::time::Instant::now() + HELD_SEND_RECHECK_INTERVAL,
HELD_SEND_RECHECK_INTERVAL,
);
'held: loop {
if let Some(task) = settled_task(task_registry, task_id) {
return task;
}
loop {
tokio::select! {
biased;
received = rx.recv() => match received {
Ok(event) if event.task_id != task_id => {}
Ok(_) | Err(RecvError::Lagged(_)) => continue 'held,
Err(RecvError::Closed) => break 'held,
},
() = door_closing(&mut closing) => break 'held,
_ = recheck.tick() => continue 'held,
}
}
}
let held = task_registry.lock().unwrap().get_task(task_id);
held.unwrap_or_else(|| routed.clone())
}
fn settled_task(task_registry: &Mutex<TaskRegistry>, task_id: &str) -> Option<A2aTask> {
let reg = task_registry.lock().unwrap();
let task = reg.get_task(task_id)?;
let state = &task.status.state;
let settled =
(state.is_terminal() && reg.has_ending(task_id)) || *state == TaskState::InputRequired;
settled.then_some(task)
}
fn visible_to(reg: &TaskRegistry, task_id: &str, caller: &TaskCaller) -> bool {
match caller {
TaskCaller::Session => true,
TaskCaller::Credential(credential) => reg.owner(task_id) == Some(credential.as_str()),
}
}
fn tenant_refusal(req: &JsonRpcRequest) -> Option<JsonRpcResponse> {
let tenant = req.params.get("tenant").filter(|tenant| *tenant != "")?;
Some(JsonRpcResponse::err(
req.id.clone(),
INVALID_PARAMS,
&format!(
"Invalid params: tenant {tenant} names a tenant; this agent serves no tenants, so \
omit tenant"
),
))
}
fn task_not_found(id: Value, task_id: Option<&str>) -> String {
task_not_found_response(id, task_id).into_http_response()
}
fn task_not_found_response(id: Value, task_id: Option<&str>) -> JsonRpcResponse {
let metadata: Vec<(&str, String)> = task_id
.map(|task_id| ("taskId", task_id.to_string()))
.into_iter()
.collect();
JsonRpcResponse::a2a_error(id, A2aError::TaskNotFound, "Task not found", &metadata)
}
fn handle_tasks_get(
id: Value,
params: &Value,
task_registry: &Arc<Mutex<TaskRegistry>>,
caller: &TaskCaller,
) -> String {
let Some(task_id) = params
.get("id")
.and_then(Value::as_str)
.filter(|task_id| !task_id.is_empty())
.map(str::to_string)
else {
return JsonRpcResponse::err(id, INVALID_PARAMS, "GetTask requires an id")
.into_http_response();
};
let reg = task_registry.lock().unwrap();
match reg
.get_task(&task_id)
.filter(|_| visible_to(®, &task_id, caller))
{
Some(task) => JsonRpcResponse::ok(id, task).into_http_response(),
None => task_not_found(id, Some(&task_id)),
}
}
const DEFAULT_LIST_PAGE_SIZE: u64 = 50;
const MAX_LIST_PAGE_SIZE: u64 = 100;
const STATUS_TIMESTAMP_AFTER_INVALID: &str =
"statusTimestampAfter must be an RFC 3339 timestamp, such as 2026-10-13T09:00:00Z";
#[derive(Debug, Clone, PartialEq)]
struct ListRequest {
filter: TaskFilter,
page_size: u64,
after: Option<ListCursor>,
include_artifacts: bool,
}
fn read_list_params(id: &Value, params: &Value) -> Result<ListRequest, JsonRpcResponse> {
let invalid = |message: &str| {
Err(JsonRpcResponse::err(
id.clone(),
INVALID_PARAMS,
&format!("Invalid params: {message}"),
))
};
let context_id = match params.get("contextId") {
None => None,
Some(Value::String(context_id)) => Some(context_id.clone()).filter(|c| !c.is_empty()),
Some(_) => return invalid("contextId must be a string"),
};
let state = match params.get("status") {
None => StateFilter::Any,
Some(Value::String(state)) => match StateFilter::from_wire_name(state) {
Some(filter) => filter,
None => {
return invalid(&format!(
"status \"{state}\" is not an A2A v1.0 TaskState name, such as {}",
TaskState::Working.wire_name()
))
}
},
Some(_) => return invalid("status must be an A2A v1.0 TaskState name"),
};
let page_size = match params.get("pageSize") {
None => DEFAULT_LIST_PAGE_SIZE,
Some(size) => match size.as_u64().filter(|size| *size >= 1) {
Some(size) => size.min(MAX_LIST_PAGE_SIZE),
None => {
return invalid(&format!(
"pageSize must be an integer of at least 1; a size above \
{MAX_LIST_PAGE_SIZE} is answered with {MAX_LIST_PAGE_SIZE}"
))
}
},
};
let after = match params.get("pageToken") {
None => None,
Some(Value::String(token)) if token.is_empty() => None,
Some(Value::String(token)) => match ListCursor::decode(token) {
Some(cursor) => Some(cursor),
None => return invalid("pageToken is not a nextPageToken this agent issued"),
},
Some(_) => return invalid("pageToken must be a string"),
};
let status_after = match params.get("statusTimestampAfter") {
None | Some(Value::Null) => None,
Some(Value::String(after)) => match chrono::DateTime::parse_from_rfc3339(after) {
Ok(after) => Some(after.with_timezone(&chrono::Utc)),
Err(_) => return invalid(STATUS_TIMESTAMP_AFTER_INVALID),
},
Some(_) => return invalid(STATUS_TIMESTAMP_AFTER_INVALID),
};
let include_artifacts = match params.get("includeArtifacts") {
None => false,
Some(Value::Bool(include)) => *include,
Some(_) => return invalid("includeArtifacts must be a boolean"),
};
Ok(ListRequest {
filter: TaskFilter {
context_id,
state,
status_after,
},
page_size,
after,
include_artifacts,
})
}
fn handle_tasks_list(
id: Value,
params: &Value,
task_registry: &Arc<Mutex<TaskRegistry>>,
caller: &TaskCaller,
) -> String {
let request = match read_list_params(&id, params) {
Ok(request) => request,
Err(response) => return response.into_http_response(),
};
let page = {
let reg = task_registry.lock().unwrap();
reg.list_tasks(
&request.filter,
request.after.as_ref(),
usize::try_from(request.page_size).unwrap_or(usize::MAX),
|task_id| visible_to(®, task_id, caller),
)
};
let tasks: Vec<A2aTask> = page
.tasks
.into_iter()
.map(|mut task| {
task.artifacts = request
.include_artifacts
.then(|| task.artifacts.unwrap_or_default());
task
})
.collect();
JsonRpcResponse::ok(
id,
serde_json::json!({
"tasks": tasks,
"nextPageToken": page.next.as_ref().map(ListCursor::encode).unwrap_or_default(),
"pageSize": request.page_size,
"totalSize": page.total,
}),
)
.into_http_response()
}
fn handle_tasks_cancel(
id: Value,
params: &Value,
task_registry: &Arc<Mutex<TaskRegistry>>,
caller: &TaskCaller,
detached: Option<&Arc<DetachedRegistry>>,
live_delegations: &LiveDelegations,
) -> String {
let Some(task_id) = params.get("id").and_then(Value::as_str).map(str::to_string) else {
return JsonRpcResponse::err(
id,
INVALID_PARAMS,
"Invalid params: CancelTask requires an id",
)
.into_http_response();
};
let (outcome, task) = {
let mut reg = task_registry.lock().unwrap();
if !visible_to(®, &task_id, caller) {
return task_not_found(id, Some(&task_id));
}
let outcome = reg.request_cancel(&task_id);
(outcome, reg.get_task(&task_id))
};
match (outcome, task) {
(CancelOutcome::Unknown, _) | (_, None) => task_not_found(id, Some(&task_id)),
(CancelOutcome::AlreadyTerminal, Some(task)) => {
let state = task.status.state.wire_name();
JsonRpcResponse::a2a_error(
id,
A2aError::TaskNotCancelable,
&format!("Task cannot be canceled: it is already {state}"),
&[("taskId", task_id.clone()), ("state", state.to_string())],
)
.into_http_response()
}
(CancelOutcome::Accepted, Some(mut task)) => {
task.artifacts = Residue::snapshot(detached, live_delegations)
.into_artifact()
.map(|artifact| vec![artifact]);
JsonRpcResponse::ok(id, task).into_http_response()
}
}
}
fn handle_stop_session(
id: Value,
task_registry: &Arc<Mutex<TaskRegistry>>,
detached: Option<&Arc<DetachedRegistry>>,
live_delegations: &LiveDelegations,
session_id: &str,
) -> String {
let canceled = task_registry.lock().unwrap().cancel_every_live(false);
let residue = Residue::snapshot(detached, live_delegations).into_json_items();
JsonRpcResponse::ok(
id,
serde_json::json!({
"session_id": session_id,
"canceled": canceled,
"residue": residue,
}),
)
.into_http_response_echoing(&[DOOR_EXTENSION_URI])
}
#[cfg(test)]
#[allow(clippy::print_stdout, clippy::print_stderr)]
mod tests {
use super::*;
use crate::errors::RuntimeError;
const ACCEPTANCES: [TaskAcceptance; 3] = [
TaskAcceptance::None,
TaskAcceptance::Single,
TaskAcceptance::Queue,
];
const HTTP_TRANSPORT: TransportCapabilities = TransportCapabilities {
kind: TransportKind::Http,
};
#[test]
fn extension_uris_keep_their_identifier_values() {
assert_eq!(
DOOR_EXTENSION_URI,
"https://docs.murmur.nexus/reference/agent-card/#murmur-door-v1"
);
assert_eq!(
CAPSULE_EXTENSION_URI,
"https://docs.murmur.nexus/reference/agent-card/#murmur-capsule-v1"
);
assert_eq!(
STREAM_EXTENSION_URI,
"https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1"
);
}
const ALL_FRAMES: &[&str] = &[
"text",
"thinking",
"tool-call-started",
"tool-call-progress",
"artifact",
"gap",
"lagged",
"connection-ack",
"capsule-closed",
];
const HTTP_FRAMES: [(TaskAcceptance, &[&str]); 3] = [
(TaskAcceptance::None, ALL_FRAMES),
(TaskAcceptance::Single, ALL_FRAMES),
(TaskAcceptance::Queue, ALL_FRAMES),
];
const PROCESS_FRAMES: [(TaskAcceptance, &[&str]); 3] = [
(TaskAcceptance::None, ALL_FRAMES),
(TaskAcceptance::Single, ALL_FRAMES),
(TaskAcceptance::Queue, ALL_FRAMES),
];
#[test]
fn stream_extension_send_streaming_message_carries_the_first_seven_frames() {
for transport in TransportKind::ALL {
for method in [
DoorMethod::SendStreamingMessage,
DoorMethod::SubscribeToTask,
] {
let carried: Vec<&str> = StreamFrame::ALL
.into_iter()
.filter(|&frame| writes_frame(method, frame, transport))
.map(StreamFrame::wire_name)
.collect();
assert_eq!(carried, ALL_FRAMES[..7], "{method:?} {transport:?}");
}
let watched: Vec<&str> = StreamFrame::ALL
.into_iter()
.filter(|&frame| writes_frame(DoorMethod::WatchSession, frame, transport))
.map(StreamFrame::wire_name)
.collect();
assert_eq!(watched, ALL_FRAMES, "{transport:?}");
}
}
#[test]
fn stream_extension_description_says_what_activation_does() {
let card = card_for(&TaskAcceptance::Queue, DeclaredPlanes::default());
let stream = card["capabilities"]["extensions"]
.as_array()
.unwrap()
.iter()
.find(|extension| extension["uri"] == STREAM_EXTENSION_URI)
.unwrap();
assert_eq!(
stream["description"],
"The murmur frames this capsule writes in addition to the A2A v1.0 events: sent on \
SendStreamingMessage and SubscribeToTask when the request's A2A-Extensions header \
names this URI, and always on murmur.WatchSession. Each is carried in a StreamResponse under \
metadata keyed by this URI."
);
assert_eq!(stream["required"], false);
}
fn every_transport() -> Vec<TransportCapabilities> {
TransportKind::ALL
.into_iter()
.map(|kind| TransportCapabilities { kind })
.collect()
}
fn card_for(acceptance: &TaskAcceptance, planes: DeclaredPlanes) -> Value {
card_for_transport(acceptance, planes, HTTP_TRANSPORT)
}
fn card_for_transport(
acceptance: &TaskAcceptance,
planes: DeclaredPlanes,
transport: TransportCapabilities,
) -> Value {
let identity = CapsuleIdentity {
capsule_name: "probe".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_probe".to_string(),
capsule_url: "http://127.0.0.1:1".to_string(),
};
build_agent_card(
&identity,
&[],
&CapabilityPolicy::default(),
acceptance,
planes,
false,
transport,
)
}
fn identity() -> CapsuleIdentity {
CapsuleIdentity {
capsule_name: "my-agent".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_019f01a940ce7761854e768ecbe3d399".to_string(),
capsule_url: "localhost:41873".to_string(),
}
}
fn tool(name: &str) -> InstalledArtifactSummary {
InstalledArtifactSummary {
name: name.to_string(),
version: "1.0.0".to_string(),
runtime: murmur_artifact::ArtifactRuntime::Tool,
implementation: None,
origin: murmur_artifact::LockOrigin::Operator,
}
}
fn full_card(acceptance: &TaskAcceptance) -> Value {
let policy = CapabilityPolicy {
shell_allow: vec!["git".to_string()],
network_allow: vec!["api.example.com".to_string()],
..CapabilityPolicy::default()
};
build_agent_card(
&identity(),
&[tool("bash")],
&policy,
acceptance,
DeclaredPlanes {
files: true,
peer_files: false,
},
false,
HTTP_TRANSPORT,
)
}
fn door_params(card: &Value) -> &serde_json::Map<String, Value> {
extension_params(card, DOOR_EXTENSION_URI).expect("the card has the door extension")
}
fn capsule_params(card: &Value) -> &serde_json::Map<String, Value> {
extension_params(card, CAPSULE_EXTENSION_URI).expect("the card has the capsule extension")
}
fn door_methods(card: &Value) -> Vec<&str> {
door_params(card)["methods"]
.as_array()
.expect("the door extension's methods are an array")
.iter()
.map(|m| m.as_str().expect("each method is a string"))
.collect()
}
fn keys(value: &Value) -> Vec<&str> {
let mut keys: Vec<&str> = value
.as_object()
.expect("an object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
keys
}
fn assert_conforms(card: &Value) {
if let Err(errors) = crate::a2a_conformance::check_agent_card(card) {
panic!("the card does not conform to lf.a2a.v1.AgentCard: {errors:#?}\n{card:#}");
}
}
#[test]
fn a2a_card_every_builder_output_conforms() {
let plane_sets = [(false, false), (true, false), (false, true), (true, true)];
let tool_sets: [&[InstalledArtifactSummary]; 2] = [&[], &[tool("bash")]];
let mut checked = 0;
for acceptance in &ACCEPTANCES {
for transport in every_transport() {
for (files, peer_files) in plane_sets {
for tools in tool_sets {
for accepts_peer_tasks in [false, true] {
let card = build_agent_card(
&identity(),
tools,
&CapabilityPolicy::default(),
acceptance,
DeclaredPlanes { files, peer_files },
accepts_peer_tasks,
transport,
);
assert_conforms(&card);
assert_eq!(door_params(&card)["peerTasks"], accepts_peer_tasks);
checked += 1;
}
}
}
}
}
assert_eq!(checked, 3 * 2 * 2 * 4 * 2);
}
#[test]
fn a2a_card_is_the_documented_document() {
let card = full_card(&TaskAcceptance::Single);
println!("{card:#}");
assert_eq!(
card,
serde_json::json!({
"name": "my-agent",
"description": "Murmur capsule my-agent 0.1.0",
"version": "0.1.0",
"supportedInterfaces": [
{ "url": "http://localhost:41873", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }
],
"capabilities": {
"streaming": true,
"pushNotifications": false,
"extendedAgentCard": false,
"extensions": [
{
"uri": "https://docs.murmur.nexus/reference/agent-card/#murmur-door-v1",
"description": "Every JSON-RPC method this door answers, A2A methods and murmur's own alike, the murmur ones named murmur.<Method>; whether it accepts tasks from peer capsules; and the forgetSession field a message's metadata may carry under this URI.",
"required": false,
"params": {
"methods": ["SendMessage", "SendStreamingMessage", "murmur.WatchSession", "GetTask", "ListTasks", "CancelTask", "SubscribeToTask", "murmur.StopSession"],
"peerTasks": false
}
},
{
"uri": "https://docs.murmur.nexus/reference/agent-card/#murmur-capsule-v1",
"description": "The session answering this address and what the capsule may do. Served only to authenticated callers once the door authenticates.",
"required": false,
"params": {
"sessionId": "ses_019f01a940ce7761854e768ecbe3d399",
"tools": ["bash"],
"shell": true,
"network": true,
"planes": ["files"]
}
},
{
"uri": "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1",
"description": "The murmur frames this capsule writes in addition to the A2A v1.0 events: sent on SendStreamingMessage and SubscribeToTask when the request's A2A-Extensions header names this URI, and always on murmur.WatchSession. Each is carried in a StreamResponse under metadata keyed by this URI.",
"required": false,
"params": {
"frames": ["text", "thinking", "tool-call-started", "tool-call-progress", "artifact", "gap", "lagged", "connection-ack", "capsule-closed"]
}
}
]
},
"securitySchemes": {},
"securityRequirements": [],
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": [
{
"id": "task",
"name": "Run a task",
"description": "Runs one task given as a text message and reports its outcome.",
"tags": ["task"]
}
]
})
);
}
#[test]
fn a2a_card_keeps_no_key_of_the_previous_shape() {
for acceptance in &ACCEPTANCES {
let card = full_card(acceptance);
for key in ["url", "session_id", "serves"] {
assert!(card.get(key).is_none(), "top-level {key}: {card}");
}
assert!(
!card.to_string().contains("cancellation"),
"no cancellation anywhere: {card}"
);
assert_eq!(
keys(&card),
[
"capabilities",
"defaultInputModes",
"defaultOutputModes",
"description",
"name",
"securityRequirements",
"securitySchemes",
"skills",
"supportedInterfaces",
"version"
]
);
assert_eq!(
keys(&card["capabilities"]),
[
"extendedAgentCard",
"extensions",
"pushNotifications",
"streaming"
]
);
}
}
#[test]
fn a2a_card_name_version_and_description_come_from_the_identity() {
let card = full_card(&TaskAcceptance::Single);
assert_eq!(card["name"], "my-agent");
assert_eq!(card["version"], "0.1.0");
assert_eq!(card["description"], "Murmur capsule my-agent 0.1.0");
}
#[test]
fn a2a_card_interface_is_the_door_url_over_http_at_1_0() {
let card = full_card(&TaskAcceptance::Single);
assert_eq!(
card["supportedInterfaces"],
serde_json::json!([{
"url": "http://localhost:41873",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0",
}])
);
assert_eq!(INTERFACE_PROTOCOL_VERSION, "1.0");
assert_eq!(jsonrpc_interface_url(&card), Some("http://localhost:41873"));
}
#[test]
fn a2a_card_interface_url_is_not_given_a_second_scheme() {
for (capsule_url, expected) in [
("localhost:41873", "http://localhost:41873"),
("localhost:41873/", "http://localhost:41873"),
("http://127.0.0.1:1", "http://127.0.0.1:1"),
("http://127.0.0.1:1/", "http://127.0.0.1:1"),
("https://agent.example.com", "https://agent.example.com"),
] {
let identity = CapsuleIdentity {
capsule_url: capsule_url.to_string(),
..identity()
};
let card = build_agent_card(
&identity,
&[],
&CapabilityPolicy::default(),
&TaskAcceptance::Single,
DeclaredPlanes::default(),
false,
HTTP_TRANSPORT,
);
assert_eq!(
card["supportedInterfaces"][0]["url"], expected,
"{capsule_url}"
);
}
}
#[test]
fn a2a_card_declares_a_public_agent_with_no_extended_card_or_push() {
for acceptance in &ACCEPTANCES {
let card = full_card(acceptance);
assert_eq!(card["securitySchemes"], serde_json::json!({}));
assert_eq!(card["securityRequirements"], serde_json::json!([]));
assert_eq!(card["capabilities"]["extendedAgentCard"], false);
assert_eq!(card["capabilities"]["pushNotifications"], false);
for modes in ["defaultInputModes", "defaultOutputModes"] {
assert_eq!(
card[modes],
serde_json::json!(["text/plain", "application/json"])
);
}
}
}
#[test]
fn a2a_card_skills_are_the_task_skill_only_when_a_task_can_start() {
for acceptance in [TaskAcceptance::Single, TaskAcceptance::Queue] {
let card = full_card(&acceptance);
let skills = card["skills"].as_array().expect("skills is an array");
assert_eq!(skills.len(), 1, "{acceptance:?}");
assert_eq!(skills[0]["id"], TASK_SKILL_ID);
assert_eq!(skills[0]["tags"], serde_json::json!([TASK_SKILL_ID]));
}
assert_eq!(TASK_SKILL_ID, "task");
let card = full_card(&TaskAcceptance::None);
assert_eq!(card["skills"], serde_json::json!([]));
}
fn extension_uris(card: &Value) -> Vec<&str> {
card["capabilities"]["extensions"]
.as_array()
.expect("extensions is an array")
.iter()
.map(|e| e["uri"].as_str().expect("each uri is a string"))
.collect()
}
#[test]
fn a2a_card_extensions_are_the_door_the_capsule_then_the_stream() {
for acceptance in &ACCEPTANCES {
let card = full_card(acceptance);
assert_eq!(
extension_uris(&card),
[
DOOR_EXTENSION_URI,
CAPSULE_EXTENSION_URI,
STREAM_EXTENSION_URI
]
);
for extension in card["capabilities"]["extensions"].as_array().unwrap() {
assert_eq!(extension["required"], false, "{extension}");
assert_eq!(
keys(extension),
["description", "params", "required", "uri"],
"{extension}"
);
}
let stream = extension_params(&card, STREAM_EXTENSION_URI)
.expect("the card has the stream extension");
assert_eq!(keys(&Value::Object(stream.clone())), ["frames"]);
}
let cards = full_cards(&TaskAcceptance::Single, Some(&bearer_authentication()));
assert_eq!(
extension_uris(&cards.public),
[DOOR_EXTENSION_URI, STREAM_EXTENSION_URI]
);
assert_eq!(
extension_uris(cards.extended.as_ref().expect("an extended card")),
[
DOOR_EXTENSION_URI,
CAPSULE_EXTENSION_URI,
STREAM_EXTENSION_URI
]
);
}
fn stream_frames(card: &Value) -> Vec<&str> {
extension_params(card, STREAM_EXTENSION_URI).expect("the card has the stream extension")
["frames"]
.as_array()
.expect("the stream extension's frames are an array")
.iter()
.map(|f| f.as_str().expect("each frame is a string"))
.collect()
}
#[test]
fn stream_extension_frames_are_pinned_per_transport() {
for (kind, table) in [
(TransportKind::Http, HTTP_FRAMES),
(TransportKind::Process, PROCESS_FRAMES),
] {
for (acceptance, expected) in &table {
let transport = TransportCapabilities { kind };
assert_eq!(
served_frames(acceptance, transport),
*expected,
"{kind:?} under {acceptance:?}"
);
}
}
}
#[test]
fn stream_extension_on_the_card_is_served_frames() {
let authentication = bearer_authentication();
for acceptance in &ACCEPTANCES {
for transport in every_transport() {
let expected = served_frames(acceptance, transport);
let card = card_for_transport(acceptance, DeclaredPlanes::default(), transport);
assert_eq!(
stream_frames(&card),
expected,
"{acceptance:?} {transport:?}"
);
let cards = build_agent_cards(
&identity(),
&[],
&CapabilityPolicy::default(),
acceptance,
DeclaredPlanes::default(),
false,
transport,
Some(&authentication),
);
assert_eq!(stream_frames(&cards.public), expected);
assert_eq!(stream_frames(cards.extended.as_ref().unwrap()), expected);
}
}
}
#[test]
fn stream_frame_every_variant_is_listed() {
for kind in TransportKind::ALL {
let transport = TransportCapabilities { kind };
let frames = served_frames(&TaskAcceptance::Single, transport);
for frame in StreamFrame::ALL {
assert!(
frames.contains(&frame.wire_name()),
"{} is never listed on {kind:?}: {frames:?}",
frame.wire_name()
);
}
}
}
#[test]
fn stream_extension_docs_name_every_frame() {
let page = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/content/reference/streaming-protocol.md"
))
.expect("the streaming protocol reference page");
assert!(
page.contains("{ #murmur-stream-v1 }"),
"no stream extension section"
);
assert!(page.contains("params.frames"), "no params.frames key");
assert!(
STREAM_EXTENSION_URI.ends_with("/reference/streaming-protocol/#murmur-stream-v1"),
"the URI addresses the section"
);
for frame in StreamFrame::ALL {
let anchor = format!("{{ #event-{} }}", frame.wire_name());
assert!(
page.lines()
.any(|line| line.starts_with('#') && line.trim_end().ends_with(&anchor)),
"no heading for {}",
frame.wire_name()
);
}
}
#[test]
fn a2a_card_capsule_extension_carries_the_session_and_permissions() {
let card = full_card(&TaskAcceptance::Single);
let params = capsule_params(&card);
assert_eq!(
keys(&Value::Object(params.clone())),
["network", "planes", "sessionId", "shell", "tools"]
);
assert_eq!(params["sessionId"], "ses_019f01a940ce7761854e768ecbe3d399");
assert_eq!(params["tools"], serde_json::json!(["bash"]));
assert_eq!(params["shell"], true);
assert_eq!(params["network"], true);
assert_eq!(
session_id_from_card(&card),
Some("ses_019f01a940ce7761854e768ecbe3d399")
);
let bare = card_for(&TaskAcceptance::Single, DeclaredPlanes::default());
let params = capsule_params(&bare);
assert_eq!(params["tools"], serde_json::json!([]));
assert_eq!(params["shell"], false);
assert_eq!(params["network"], false);
}
#[test]
fn a2a_card_tools_are_the_llm_visible_artifacts() {
let hook = InstalledArtifactSummary {
runtime: murmur_artifact::ArtifactRuntime::Hook,
..tool("guard")
};
let card = build_agent_card(
&identity(),
&[tool("bash"), hook, tool("search")],
&CapabilityPolicy::default(),
&TaskAcceptance::Single,
DeclaredPlanes::default(),
false,
HTTP_TRANSPORT,
);
assert_eq!(
capsule_params(&card)["tools"],
serde_json::json!(["bash", "search"])
);
}
#[test]
fn a_card_without_the_capsule_extension_still_conforms() {
for acceptance in &ACCEPTANCES {
let mut card = full_card(acceptance);
card["capabilities"]["extensions"]
.as_array_mut()
.expect("extensions is an array")
.retain(|extension| extension["uri"] != CAPSULE_EXTENSION_URI);
assert_conforms(&card);
assert!(extension_params(&card, DOOR_EXTENSION_URI).is_some());
assert!(extension_params(&card, CAPSULE_EXTENSION_URI).is_none());
assert!(card["capabilities"]["streaming"].is_boolean());
let serialised = card.to_string();
for key in ["sessionId", "tools", "shell", "network", "planes"] {
assert!(
!serialised.contains(key),
"{key} is extended-card material and belongs to the capsule extension alone: {serialised}"
);
}
}
}
#[test]
fn a2a_card_door_extension_states_the_peer_task_posture() {
for accepts_peer_tasks in [false, true] {
let card = build_agent_card(
&identity(),
&[],
&CapabilityPolicy::default(),
&TaskAcceptance::Single,
DeclaredPlanes::default(),
accepts_peer_tasks,
HTTP_TRANSPORT,
);
assert_eq!(
keys(&Value::Object(door_params(&card).clone())),
["methods", "peerTasks"]
);
assert_eq!(door_params(&card)["peerTasks"], accepts_peer_tasks);
assert_eq!(
capsule_params(&card).get("peerTasks"),
None,
"the posture is public, so it is not capsule-extension material"
);
assert_eq!(card["securitySchemes"], serde_json::json!({}));
}
}
#[test]
fn a2a_card_peer_task_posture_survives_onto_both_cards_of_an_authenticated_door() {
let authentication = bearer_authentication();
let cards_for = |accepts_peer_tasks: bool| {
build_agent_cards(
&identity(),
&[],
&CapabilityPolicy::default(),
&TaskAcceptance::Single,
DeclaredPlanes::default(),
accepts_peer_tasks,
HTTP_TRANSPORT,
Some(&authentication),
)
};
for accepts_peer_tasks in [false, true] {
let cards = cards_for(accepts_peer_tasks);
assert_eq!(door_params(&cards.public)["peerTasks"], accepts_peer_tasks);
let extended = cards
.extended
.as_ref()
.expect("an authenticated door has one");
assert_eq!(door_params(extended)["peerTasks"], accepts_peer_tasks);
assert!(door_methods(&cards.public).contains(&"GetExtendedAgentCard"));
}
let (refusing, accepting) = (cards_for(false), cards_for(true));
assert_eq!(
refusing.public["securitySchemes"],
accepting.public["securitySchemes"]
);
assert_eq!(
refusing.public["securityRequirements"],
accepting.public["securityRequirements"]
);
}
fn bearer_authentication() -> NetworkAuthentication {
NetworkAuthentication {
scheme: murmur_artifact::AuthenticationScheme::Bearer,
credentials: vec![murmur_artifact::DoorCredential {
name: "watcher".to_string(),
scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
}],
}
}
fn full_cards(
acceptance: &TaskAcceptance,
authentication: Option<&NetworkAuthentication>,
) -> AgentCards {
let policy = CapabilityPolicy {
shell_allow: vec!["git".to_string()],
network_allow: vec!["api.example.com".to_string()],
..CapabilityPolicy::default()
};
build_agent_cards(
&identity(),
&[tool("bash")],
&policy,
acceptance,
DeclaredPlanes {
files: true,
peer_files: false,
},
false,
HTTP_TRANSPORT,
authentication,
)
}
#[test]
fn a2a_card_every_public_and_extended_card_conforms() {
let plane_sets = [(false, false), (true, false), (false, true), (true, true)];
let tool_sets: [&[InstalledArtifactSummary]; 2] = [&[], &[tool("bash")]];
let policies = [
CapabilityPolicy::default(),
CapabilityPolicy {
shell_allow: vec!["git".to_string()],
network_allow: vec!["api.example.com".to_string()],
..CapabilityPolicy::default()
},
];
let authentication = bearer_authentication();
let mut checked = 0;
for authenticated in [None, Some(&authentication)] {
for acceptance in &ACCEPTANCES {
for transport in every_transport() {
for (files, peer_files) in plane_sets {
for tools in tool_sets {
for policy in &policies {
for accepts_peer_tasks in [false, true] {
let planes = DeclaredPlanes { files, peer_files };
let cards = build_agent_cards(
&identity(),
tools,
policy,
acceptance,
planes,
accepts_peer_tasks,
transport,
authenticated,
);
assert_conforms(&cards.public);
let base = build_agent_card(
&identity(),
tools,
policy,
acceptance,
planes,
accepts_peer_tasks,
transport,
);
match authenticated {
None => {
assert_eq!(cards.public, base);
assert_eq!(cards.extended, None);
}
Some(_) => {
let extended = authenticated_card(base, acceptance);
assert_conforms(&extended);
assert_eq!(cards.extended.as_ref(), Some(&extended));
let mut stripped = extended.clone();
stripped["capabilities"]["extensions"]
.as_array_mut()
.unwrap()
.retain(|e| e["uri"] != CAPSULE_EXTENSION_URI);
assert_eq!(cards.public, stripped);
}
}
checked += 1;
}
}
}
}
}
}
}
assert_eq!(checked, 2 * 3 * 2 * 2 * 4 * 2 * 2);
}
#[test]
fn a2a_card_authenticated_public_card_is_the_documented_document() {
let cards = full_cards(&TaskAcceptance::Single, Some(&bearer_authentication()));
println!("{:#}", cards.public);
assert_eq!(
cards.public,
serde_json::json!({
"name": "my-agent",
"description": "Murmur capsule my-agent 0.1.0",
"version": "0.1.0",
"supportedInterfaces": [
{ "url": "http://localhost:41873", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }
],
"capabilities": {
"streaming": true,
"pushNotifications": false,
"extendedAgentCard": true,
"extensions": [
{
"uri": "https://docs.murmur.nexus/reference/agent-card/#murmur-door-v1",
"description": "Every JSON-RPC method this door answers, A2A methods and murmur's own alike, the murmur ones named murmur.<Method>; whether it accepts tasks from peer capsules; and the forgetSession field a message's metadata may carry under this URI.",
"required": false,
"params": {
"methods": ["SendMessage", "SendStreamingMessage", "murmur.WatchSession", "GetTask", "ListTasks", "CancelTask", "SubscribeToTask", "murmur.StopSession", "GetExtendedAgentCard"],
"peerTasks": false
}
},
{
"uri": "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1",
"description": "The murmur frames this capsule writes in addition to the A2A v1.0 events: sent on SendStreamingMessage and SubscribeToTask when the request's A2A-Extensions header names this URI, and always on murmur.WatchSession. Each is carried in a StreamResponse under metadata keyed by this URI.",
"required": false,
"params": {
"frames": ["text", "thinking", "tool-call-started", "tool-call-progress", "artifact", "gap", "lagged", "connection-ack", "capsule-closed"]
}
}
]
},
"securitySchemes": {
"bearer": {
"httpAuthSecurityScheme": {
"scheme": "Bearer",
"description": "A token this capsule's runtime mints at launch and accepts until the session ends."
}
}
},
"securityRequirements": [ { "schemes": { "bearer": { "list": [] } } } ],
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": [
{
"id": "task",
"name": "Run a task",
"description": "Runs one task given as a text message and reports its outcome.",
"tags": ["task"],
"securityRequirements": [
{ "schemes": { "bearer": { "list": ["SendMessage"] } } },
{ "schemes": { "bearer": { "list": ["SendStreamingMessage"] } } }
]
}
]
})
);
}
#[test]
fn a2a_card_extended_card_is_the_documented_v1_document() {
let cards = full_cards(&TaskAcceptance::Single, Some(&bearer_authentication()));
let extended = cards
.extended
.expect("an authenticated door has an extended card");
println!("{extended:#}");
assert_eq!(
extended,
serde_json::json!({
"name": "my-agent",
"description": "Murmur capsule my-agent 0.1.0",
"version": "0.1.0",
"supportedInterfaces": [
{ "url": "http://localhost:41873", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }
],
"capabilities": {
"streaming": true,
"pushNotifications": false,
"extendedAgentCard": true,
"extensions": [
{ "uri": "https://docs.murmur.nexus/reference/agent-card/#murmur-door-v1", "description": "Every JSON-RPC method this door answers, A2A methods and murmur's own alike, the murmur ones named murmur.<Method>; whether it accepts tasks from peer capsules; and the forgetSession field a message's metadata may carry under this URI.", "required": false,
"params": { "methods": ["SendMessage", "SendStreamingMessage", "murmur.WatchSession", "GetTask", "ListTasks", "CancelTask", "SubscribeToTask", "murmur.StopSession", "GetExtendedAgentCard"], "peerTasks": false } },
{ "uri": "https://docs.murmur.nexus/reference/agent-card/#murmur-capsule-v1", "description": "The session answering this address and what the capsule may do. Served only to authenticated callers once the door authenticates.", "required": false,
"params": { "sessionId": "ses_019f01a940ce7761854e768ecbe3d399", "tools": ["bash"], "shell": true, "network": true, "planes": ["files"] } },
{ "uri": "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1", "description": "The murmur frames this capsule writes in addition to the A2A v1.0 events: sent on SendStreamingMessage and SubscribeToTask when the request's A2A-Extensions header names this URI, and always on murmur.WatchSession. Each is carried in a StreamResponse under metadata keyed by this URI.", "required": false,
"params": { "frames": ["text", "thinking", "tool-call-started", "tool-call-progress", "artifact", "gap", "lagged", "connection-ack", "capsule-closed"] } }
]
},
"securitySchemes": {
"bearer": {
"httpAuthSecurityScheme": {
"scheme": "Bearer",
"description": "A token this capsule's runtime mints at launch and accepts until the session ends."
}
}
},
"securityRequirements": [ { "schemes": { "bearer": { "list": [] } } } ],
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": [
{
"id": "task",
"name": "Run a task",
"description": "Runs one task given as a text message and reports its outcome.",
"tags": ["task"],
"securityRequirements": [
{ "schemes": { "bearer": { "list": ["SendMessage"] } } },
{ "schemes": { "bearer": { "list": ["SendStreamingMessage"] } } }
]
}
]
})
);
assert_conforms(&extended);
assert_eq!(
session_id_from_card(&extended),
Some("ses_019f01a940ce7761854e768ecbe3d399")
);
}
#[test]
fn a2a_card_authenticated_public_card_discloses_no_capsule_material() {
let authentication = bearer_authentication();
for acceptance in &ACCEPTANCES {
let cards = full_cards(acceptance, Some(&authentication));
let serialised = cards.public.to_string();
for key in ["sessionId", "tools", "shell", "network", "planes"] {
assert!(
!serialised.contains(key),
"{key} belongs to the extended card alone: {serialised}"
);
}
assert_eq!(session_id_from_card(&cards.public), None);
assert!(extension_params(&cards.public, CAPSULE_EXTENSION_URI).is_none());
}
}
#[test]
fn a2a_card_a_door_that_starts_no_task_has_no_skill_requirements() {
let cards = full_cards(&TaskAcceptance::None, Some(&bearer_authentication()));
assert_eq!(cards.public["skills"], serde_json::json!([]));
assert_eq!(
door_methods(&cards.public),
[
"murmur.WatchSession",
"GetTask",
"ListTasks",
"CancelTask",
"murmur.StopSession",
"GetExtendedAgentCard"
]
);
let extended = cards.extended.unwrap();
assert_eq!(extended["skills"], serde_json::json!([]));
assert_eq!(
extended["securityRequirements"],
serde_json::json!([{"schemes": {"bearer": {"list": []}}}])
);
}
#[test]
fn a2a_card_public_door_cards_are_build_agent_card() {
for acceptance in &ACCEPTANCES {
let cards = full_cards(acceptance, None);
assert_eq!(cards.public, full_card(acceptance));
assert_eq!(cards.extended, None);
assert!(!door_methods(&cards.public).contains(&"GetExtendedAgentCard"));
}
}
#[test]
fn session_id_from_card_reads_the_capsule_extension_only() {
let card = full_card(&TaskAcceptance::Single);
assert_eq!(
session_id_from_card(&card),
Some("ses_019f01a940ce7761854e768ecbe3d399")
);
let previous = serde_json::json!({
"name": "my-agent",
"version": "0.1.0",
"url": "localhost:41873",
"session_id": "ses_019f01a940ce7761854e768ecbe3d399",
});
assert_eq!(session_id_from_card(&previous), None);
let mut without = card.clone();
without["capabilities"]["extensions"]
.as_array_mut()
.unwrap()
.retain(|extension| extension["uri"] != CAPSULE_EXTENSION_URI);
assert_eq!(session_id_from_card(&without), None);
let mut empty = card.clone();
empty["capabilities"]["extensions"][1]["params"]["sessionId"] = Value::from("");
assert_eq!(session_id_from_card(&empty), None);
assert_eq!(session_id_from_card(&Value::from("not a card")), None);
}
#[test]
fn jsonrpc_interface_url_picks_the_first_jsonrpc_interface() {
let card = serde_json::json!({
"supportedInterfaces": [
{ "url": "https://grpc.example.com", "protocolBinding": "GRPC", "protocolVersion": "1.0" },
{ "url": "http://localhost:1", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" },
{ "url": "http://localhost:2", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" },
]
});
assert_eq!(jsonrpc_interface_url(&card), Some("http://localhost:1"));
for card in [
serde_json::json!({
"supportedInterfaces": [
{ "url": "https://grpc.example.com", "protocolBinding": "GRPC", "protocolVersion": "1.0" },
]
}),
serde_json::json!({ "supportedInterfaces": [] }),
serde_json::json!({
"supportedInterfaces": [{ "url": "", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }]
}),
serde_json::json!({ "url": "localhost:41873" }),
] {
assert_eq!(jsonrpc_interface_url(&card), None, "{card}");
}
}
#[test]
fn card_capabilities_are_the_method_on_every_transport() {
for transport in every_transport() {
let card = card_for_transport(
&TaskAcceptance::Single,
DeclaredPlanes::default(),
transport,
);
assert_eq!(card["capabilities"]["streaming"], true, "{card}");
let methods = door_methods(&card);
assert!(methods.contains(&"SendStreamingMessage"), "{card}");
assert!(methods.contains(&"CancelTask"), "{card}");
}
}
#[test]
fn a_door_that_starts_no_task_advertises_no_streaming() {
for transport in every_transport() {
let card =
card_for_transport(&TaskAcceptance::None, DeclaredPlanes::default(), transport);
assert_eq!(card["capabilities"]["streaming"], false, "{card}");
assert!(door_methods(&card).contains(&"CancelTask"), "{card}");
}
}
fn message_send_params(message_id: &str) -> Value {
serde_json::json!({
"message": {
"messageId": message_id,
"role": "ROLE_USER",
"parts": [{"text": "hello"}]
}
})
}
fn send_now(
id: Value,
params: &Value,
task_registry: &Arc<Mutex<TaskRegistry>>,
task_tx: &mpsc::Sender<IncomingTask>,
sender: MessageSender,
forgettable_session: bool,
) -> String {
let mut params = params.clone();
params["configuration"] = serde_json::json!({ "returnImmediately": true });
match accept_message_send(
id,
¶ms,
task_registry,
task_tx,
sender,
forgettable_session,
) {
SendAnswer::Now(response) => response,
SendAnswer::Held(_) => unreachable!("a message returning immediately is never held"),
}
}
fn operator() -> MessageSender {
member(None)
}
fn member(caller: Option<&str>) -> MessageSender {
MessageSender {
traceparent: None,
provenance: TaskProvenance::derive(TaskOrigin::User, None),
caller_member: caller.map(str::to_string),
caller: caller.map_or(TaskCaller::Session, |member| {
TaskCaller::Credential(format!("member:{member}"))
}),
}
}
fn credential(credential: &str) -> TaskCaller {
TaskCaller::Credential(credential.to_string())
}
fn response_json(response: &str) -> Value {
let body = &response[response.find("\r\n\r\n").expect("header terminator") + 4..];
serde_json::from_str(body).unwrap_or_else(|e| panic!("{e}: {body}"))
}
fn forget_params(message_id: &str, forget: bool) -> Value {
let mut params = message_send_params(message_id);
params["message"]["metadata"] =
serde_json::json!({ DOOR_EXTENSION_URI: { "forgetSession": forget } });
params
}
#[test]
fn a_forget_asked_of_a_capsule_with_no_harness_session_is_refused_before_routing() {
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let raw = send_now(
serde_json::json!(1),
&forget_params("msg_forget", true),
&task_registry,
&task_tx,
operator(),
false,
);
let response = response_json(&raw);
assert_eq!(response["error"]["code"], INVALID_PARAMS, "{response}");
assert_eq!(
response["error"]["message"],
"forgetSession asks this capsule to forget the harness session this context names, \
and only a capsule on inference.transport: process has one"
);
assert!(response["error"].get("data").is_none(), "{response}");
assert!(!split_response(&raw).0.contains("A2A-Extensions"), "{raw}");
assert!(
task_rx.try_recv().is_err(),
"nothing was handed to the loop"
);
assert!(task_registry.lock().unwrap().history.is_empty());
let raw = send_now(
serde_json::json!(2),
&forget_params("msg_keep", false),
&task_registry,
&task_tx,
operator(),
false,
);
assert_eq!(
response_json(&raw)["result"]["task"]["status"]["state"],
"TASK_STATE_SUBMITTED"
);
assert!(
split_response(&raw)
.0
.contains(&format!("\r\nA2A-Extensions: {DOOR_EXTENSION_URI}")),
"{raw}"
);
assert!(!task_rx.try_recv().unwrap().forget_session);
}
#[test]
fn a_forget_reaches_the_task_its_message_starts_and_echoes_the_door_extension() {
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let raw = send_now(
serde_json::json!(1),
&forget_params("msg_forget", true),
&task_registry,
&task_tx,
operator(),
true,
);
assert_eq!(
response_json(&raw)["result"]["task"]["status"]["state"],
"TASK_STATE_SUBMITTED"
);
assert!(
split_response(&raw)
.0
.contains(&format!("\r\nA2A-Extensions: {DOOR_EXTENSION_URI}")),
"{raw}"
);
assert!(task_rx.try_recv().unwrap().forget_session);
let raw = send_now(
serde_json::json!(2),
&message_send_params("msg_plain"),
&task_registry,
&task_tx,
operator(),
true,
);
assert!(!split_response(&raw).0.contains("A2A-Extensions"), "{raw}");
assert!(!task_rx.try_recv().unwrap().forget_session);
}
#[test]
fn a_closed_registry_answers_message_send_rejected_and_enqueues_nothing() {
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
assert!(task_registry.lock().unwrap().close_to_new_work().is_empty());
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let response = response_json(&send_now(
serde_json::json!(1),
&message_send_params("msg_late"),
&task_registry,
&task_tx,
operator(),
false,
));
assert_eq!(
response["result"]["task"]["status"]["state"], "TASK_STATE_REJECTED",
"{response}"
);
assert!(
task_rx.try_recv().is_err(),
"nothing was handed to the loop"
);
let reg = task_registry.lock().unwrap();
assert_eq!(reg.pending_count, 0);
assert!(reg.history.is_empty(), "the refused id is not held");
}
#[test]
fn a_formation_member_cancels_only_the_tasks_it_submitted() {
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
{
let mut reg = task_registry.lock().unwrap();
reg.enqueue("tsk_mine", "ctx_mine");
reg.record_owner("tsk_mine", "member:planner");
reg.enqueue("tsk_theirs", "ctx_theirs");
reg.record_owner("tsk_theirs", "member:reviewer");
reg.enqueue("tsk_operator", "ctx_operator");
}
let live = LiveDelegations::new();
let cancel = |task_id: &str| {
response_json(&handle_tasks_cancel(
serde_json::json!(1),
&serde_json::json!({"id": task_id}),
&task_registry,
&credential("member:planner"),
None,
&live,
))
};
let canceled = cancel("tsk_mine");
assert_eq!(
canceled["result"]["status"]["state"], "TASK_STATE_CANCELED",
"{canceled}"
);
for other in ["tsk_theirs", "tsk_operator"] {
let refused = cancel(other);
assert_eq!(refused["error"]["code"], -32001, "{refused}");
assert_eq!(refused["error"]["message"], "Task not found", "{refused}");
let state = task_registry.lock().unwrap().get_task(other).unwrap();
assert_ne!(
serde_json::to_value(&state).unwrap()["status"]["state"],
"TASK_STATE_CANCELED",
"{other} was cancelled by a member that did not submit it"
);
}
let ended = cancel("tsk_mine");
assert_eq!(ended["error"]["code"], -32002, "{ended}");
let info = &ended["error"]["data"][0];
assert_eq!(info["reason"], "TASK_NOT_CANCELABLE", "{ended}");
assert_eq!(
info["metadata"],
serde_json::json!({"taskId": "tsk_mine", "state": "TASK_STATE_CANCELED"})
);
let unknown = cancel("tsk_never");
assert_eq!(unknown["error"]["code"], -32001, "{unknown}");
assert_eq!(
unknown["error"]["data"][0]["metadata"],
serde_json::json!({"taskId": "tsk_never"})
);
}
#[test]
fn tasks_get_serves_a_refused_task_as_rejected() {
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
task_registry
.lock()
.unwrap()
.enqueue("tsk_refused", "ctx_refused");
task_registry.lock().unwrap().close_to_new_work();
let response = handle_tasks_get(
serde_json::json!(1),
&serde_json::json!({"id": "tsk_refused"}),
&task_registry,
&TaskCaller::Session,
);
assert!(
response.contains(r#""state":"TASK_STATE_REJECTED""#),
"{response}"
);
let response = response_json(&response);
assert_eq!(response["result"]["id"], "tsk_refused");
assert_eq!(response["result"]["contextId"], "ctx_refused");
}
fn send(
registry: &Arc<Mutex<TaskRegistry>>,
task_tx: &mpsc::Sender<IncomingTask>,
caller: Option<&str>,
message: Value,
) -> Value {
response_json(&send_now(
serde_json::json!(1),
&serde_json::json!({ "message": message }),
registry,
task_tx,
member(caller),
false,
))
}
fn naming(task_id: &str, context_id: Option<&str>, text: &str) -> Value {
let mut message = serde_json::json!({"messageId": "msg_reply", "role": "ROLE_USER",
"taskId": task_id, "parts": [{"text": text}]});
if let Some(context_id) = context_id {
message["contextId"] = Value::from(context_id);
}
message
}
fn routing_registry() -> (Arc<Mutex<TaskRegistry>>, oneshot::Receiver<String>) {
let mut reg = TaskRegistry::new(4, TaskAcceptance::Queue);
reg.enqueue("tsk_done", "ctx_done");
reg.start_task(
"tsk_done".to_string(),
"ctx_done".to_string(),
crate::lanes::TaskLane::User,
);
reg.finish_task(TaskState::Completed);
reg.record_ending("tsk_done", "done", Some("four"), false, &[], &[]);
reg.enqueue("tsk_wait", "ctx_wait");
reg.record_owner("tsk_wait", "member:planner");
reg.start_task(
"tsk_wait".to_string(),
"ctx_wait".to_string(),
crate::lanes::TaskLane::User,
);
let (tx, rx) = oneshot::channel();
reg.set_input_required("tsk_wait", "which branch?".to_string(), tx)
.unwrap();
reg.enqueue("tsk_queued", "ctx_q");
reg.queue_depth = 1;
(Arc::new(Mutex::new(reg)), rx)
}
fn error_of(answer: &Value) -> (i64, Value) {
(
answer["error"]["code"].as_i64().unwrap_or_default(),
answer["error"]["data"][0]["metadata"].clone(),
)
}
#[test]
fn a_message_is_routed_by_the_task_it_names() {
let (registry, mut waiter) = routing_registry();
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let state = |task_id: &str| {
registry
.lock()
.unwrap()
.get_task(task_id)
.unwrap()
.status
.state
};
for task_id in [None, Some("")] {
let mut message = serde_json::json!({"messageId": "m", "role": "ROLE_USER",
"parts": [{"text": "hi"}]});
if let Some(task_id) = task_id {
message["taskId"] = Value::from(task_id);
}
let answer = send(®istry, &task_tx, None, message);
assert_eq!(
answer["result"]["task"]["status"]["state"], "TASK_STATE_REJECTED",
"{answer}"
);
assert_eq!(state("tsk_wait"), TaskState::InputRequired);
}
let answer = send(®istry, &task_tx, None, naming("tsk_nope", None, "x"));
assert_eq!(
error_of(&answer),
(-32001, serde_json::json!({"taskId": "tsk_nope"}))
);
let answer = send(
®istry,
&task_tx,
Some("reviewer"),
naming("tsk_wait", None, "x"),
);
assert_eq!(
error_of(&answer),
(-32001, serde_json::json!({"taskId": "tsk_wait"}))
);
let answer = send(®istry, &task_tx, None, naming("tsk_done", None, "x"));
assert_eq!(
error_of(&answer),
(
-32004,
serde_json::json!({"taskId": "tsk_done", "state": "TASK_STATE_COMPLETED"})
)
);
assert!(
answer["error"]["message"]
.as_str()
.unwrap()
.contains("without taskId"),
"{answer}"
);
let answer = send(®istry, &task_tx, None, naming("tsk_queued", None, "x"));
assert_eq!(
error_of(&answer),
(
-32004,
serde_json::json!({"taskId": "tsk_queued", "state": "TASK_STATE_SUBMITTED"})
)
);
let answer = send(
®istry,
&task_tx,
None,
naming("tsk_wait", Some("ctx_other"), "x"),
);
assert_eq!(answer["error"]["code"], -32602, "{answer}");
let text = answer["error"]["message"].as_str().unwrap();
assert!(
text.contains("ctx_other") && text.contains("ctx_wait"),
"{text}"
);
assert!(waiter.try_recv().is_err(), "nothing was delivered yet");
let answer = send(
®istry,
&task_tx,
Some("planner"),
naming("tsk_wait", Some("ctx_wait"), "main"),
);
assert_eq!(
answer["result"]["task"]["status"]["state"], "TASK_STATE_WORKING",
"{answer}"
);
assert_eq!(answer["result"]["task"]["id"], "tsk_wait");
assert_eq!(waiter.try_recv().unwrap(), "main");
assert!(task_rx.try_recv().is_err(), "no task was started");
let answer = send(®istry, &task_tx, None, naming("tsk_wait", None, "x"));
assert_eq!(
error_of(&answer),
(
-32004,
serde_json::json!({"taskId": "tsk_wait", "state": "TASK_STATE_WORKING"})
)
);
}
#[test]
fn a_message_naming_no_task_never_reaches_a_waiting_one() {
let (registry, mut waiter) = routing_registry();
registry.lock().unwrap().queue_depth = 4;
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let answer = send(
®istry,
&task_tx,
None,
serde_json::json!({"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "hi"}]}),
);
assert_eq!(
answer["result"]["task"]["status"]["state"], "TASK_STATE_SUBMITTED",
"{answer}"
);
assert!(answer["result"].get("id").is_none(), "{answer}");
assert!(waiter.try_recv().is_err());
let incoming = task_rx.try_recv().unwrap();
assert_eq!(incoming.message_text, "hi");
assert_eq!(incoming.part_kinds, ["text"]);
}
#[test]
fn referenced_tasks_reach_the_task_as_the_sender_may_see_them() {
let (registry, mut waiter) = routing_registry();
registry.lock().unwrap().queue_depth = 4;
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let mut message = naming("tsk_wait", None, "go");
message["referenceTaskIds"] = serde_json::json!(["tsk_done", "tsk_done", "tsk_none"]);
let answer = send(®istry, &task_tx, Some("planner"), message);
assert_eq!(
answer["result"]["task"]["status"]["state"], "TASK_STATE_WORKING",
"{answer}"
);
assert_eq!(
waiter.try_recv().unwrap(),
"go\n\nReferenced tasks:\n\
- tsk_done: not a task this capsule holds\n\
- tsk_none: not a task this capsule holds"
);
let answer = send(
®istry,
&task_tx,
None,
serde_json::json!({"messageId": "m", "role": "ROLE_USER",
"parts": [{"text": "sum"}, {"data": [1, 2]}],
"referenceTaskIds": ["tsk_done", "tsk_done"]}),
);
assert_eq!(
answer["result"]["task"]["status"]["state"], "TASK_STATE_SUBMITTED",
"{answer}"
);
let incoming = task_rx.try_recv().unwrap();
assert_eq!(
incoming.message_text,
"sum\n```data\n[\n 1,\n 2\n]\n```\n\nReferenced tasks:\n\
- tsk_done: completed\n```response\nfour\n```"
);
assert_eq!(incoming.reference_task_ids, ["tsk_done"]);
assert_eq!(incoming.part_kinds, ["text", "data"]);
}
#[test]
fn a_refused_message_starts_nothing_and_delivers_nothing() {
let (registry, mut waiter) = routing_registry();
registry.lock().unwrap().queue_depth = 4;
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let held = registry.lock().unwrap().history.len();
for (parts, code) in [
(
serde_json::json!([{"url": "https://example.com/x.pdf"}]),
-32005,
),
(serde_json::json!([{"raw": "aGk="}]), -32005),
(serde_json::json!([{"text": 1}]), -32602),
] {
for task_id in [None, Some("tsk_wait")] {
let mut message =
serde_json::json!({"messageId": "m", "role": "ROLE_USER", "parts": parts});
if let Some(task_id) = task_id {
message["taskId"] = Value::from(task_id);
}
let answer = send(®istry, &task_tx, Some("planner"), message);
assert_eq!(answer["error"]["code"], code, "{answer}");
}
}
assert!(waiter.try_recv().is_err());
assert!(task_rx.try_recv().is_err());
let reg = registry.lock().unwrap();
assert_eq!(reg.history.len(), held);
assert_eq!(
reg.get_task("tsk_wait").unwrap().status.state,
TaskState::InputRequired
);
}
#[test]
fn get_task_requires_an_id() {
let (registry, _waiter) = routing_registry();
for params in [
serde_json::json!({}),
serde_json::json!({"id": ""}),
serde_json::json!({"id": 7}),
] {
let answer = response_json(&handle_tasks_get(
serde_json::json!(1),
¶ms,
®istry,
&TaskCaller::Session,
));
assert_eq!(answer["error"]["code"], -32602, "{answer}");
assert_eq!(answer["error"]["message"], "GetTask requires an id");
}
let answer = response_json(&handle_tasks_get(
serde_json::json!(1),
&serde_json::json!({"id": "tsk_done", "historyLength": 3}),
®istry,
&TaskCaller::Session,
));
assert_eq!(answer["result"]["status"]["state"], "TASK_STATE_COMPLETED");
}
fn list(registry: &Arc<Mutex<TaskRegistry>>, caller: &TaskCaller, params: Value) -> Value {
response_json(&handle_tasks_list(
serde_json::json!(1),
¶ms,
registry,
caller,
))
}
#[test]
fn list_status_timestamp_after_keeps_tasks_changed_at_or_after_it() {
let (registry, _waiter) = routing_registry();
let total = |after: Value| {
let answer = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"statusTimestampAfter": after}),
);
answer["result"]["totalSize"]
.as_u64()
.unwrap_or_else(|| panic!("{after}: {answer}"))
};
assert_eq!(total(Value::Null), 3);
assert_eq!(total(serde_json::json!("2000-01-01T00:00:00Z")), 3);
assert_eq!(total(serde_json::json!("2000-01-01T02:00:00+02:00")), 3);
assert_eq!(total(serde_json::json!("2999-01-01T00:00:00Z")), 0);
let newest = registry.lock().unwrap().get_task("tsk_queued").unwrap();
let cutoff = newest.status.timestamp.clone().unwrap();
let answer = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"statusTimestampAfter": cutoff}),
);
let tasks = answer["result"]["tasks"].as_array().unwrap();
assert_eq!(tasks[0]["id"], "tsk_queued", "{answer}");
assert!(tasks
.iter()
.all(|task| { task["status"]["timestamp"].as_str().unwrap() >= cutoff.as_str() }));
}
#[test]
fn list_params_are_refused_by_name() {
let (registry, _waiter) = routing_registry();
for (params, code, names) in [
(serde_json::json!({"contextId": 7}), -32602, "contextId"),
(serde_json::json!({"status": "done"}), -32602, "status"),
(serde_json::json!({"status": 3}), -32602, "status"),
(serde_json::json!({"pageSize": 0}), -32602, "pageSize"),
(serde_json::json!({"pageSize": -1}), -32602, "pageSize"),
(serde_json::json!({"pageSize": 2.5}), -32602, "pageSize"),
(serde_json::json!({"pageSize": "2"}), -32602, "pageSize"),
(
serde_json::json!({"pageToken": "not-a-token"}),
-32602,
"pageToken",
),
(serde_json::json!({"pageToken": 1}), -32602, "pageToken"),
(
serde_json::json!({"statusTimestampAfter": "yesterday"}),
-32602,
"statusTimestampAfter",
),
(
serde_json::json!({"statusTimestampAfter": 42}),
-32602,
"statusTimestampAfter",
),
(
serde_json::json!({"statusTimestampAfter": true}),
-32602,
"statusTimestampAfter",
),
(
serde_json::json!({"statusTimestampAfter": "2026-13-01T00:00:00Z"}),
-32602,
"statusTimestampAfter",
),
(
serde_json::json!({"includeArtifacts": "yes"}),
-32602,
"includeArtifacts",
),
(
serde_json::json!({"includeArtifacts": 1, "status": "done"}),
-32602,
"status",
),
] {
let answer = list(®istry, &TaskCaller::Session, params.clone());
assert_eq!(answer["error"]["code"], code, "{params}: {answer}");
let message = answer["error"]["message"].as_str().unwrap();
assert!(message.contains(names), "{params}: {message}");
}
let not_a_timestamp = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"statusTimestampAfter": "yesterday"}),
);
assert_eq!(
not_a_timestamp["error"]["message"],
"Invalid params: statusTimestampAfter must be an RFC 3339 timestamp, such as \
2026-10-13T09:00:00Z"
);
for accepted in [
serde_json::json!({}),
serde_json::json!({"contextId": "", "status": "TASK_STATE_UNSPECIFIED",
"pageToken": "", "statusTimestampAfter": null, "historyLength": 3,
"includeArtifacts": false, "somethingElse": true}),
] {
let answer = list(®istry, &TaskCaller::Session, accepted.clone());
assert_eq!(answer["result"]["totalSize"], 3, "{accepted}: {answer}");
assert_eq!(answer["result"]["pageSize"], 50, "{accepted}: {answer}");
assert_eq!(
answer["result"]["nextPageToken"], "",
"{accepted}: {answer}"
);
}
let lowered = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"pageSize": 500}),
);
assert_eq!(lowered["result"]["pageSize"], 100, "{lowered}");
let nothing = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"status": "TASK_STATE_AUTH_REQUIRED"}),
);
assert_eq!(
nothing["result"],
serde_json::json!({"tasks": [], "nextPageToken": "", "pageSize": 50, "totalSize": 0})
);
}
#[test]
fn list_tasks_shows_each_caller_what_it_may_see() {
let (registry, _waiter) = routing_registry();
let ids = |answer: &Value| -> Vec<String> {
answer["result"]["tasks"]
.as_array()
.unwrap()
.iter()
.map(|task| task["id"].as_str().unwrap().to_string())
.collect()
};
let planner = list(
®istry,
&credential("member:planner"),
serde_json::json!({}),
);
assert_eq!(ids(&planner), ["tsk_wait"]);
assert_eq!(planner["result"]["totalSize"], 1);
let alice = list(®istry, &credential("alice"), serde_json::json!({}));
assert_eq!(
alice["result"],
serde_json::json!({"tasks": [], "nextPageToken": "", "pageSize": 50, "totalSize": 0})
);
let session = list(®istry, &TaskCaller::Session, serde_json::json!({}));
assert_eq!(ids(&session), ["tsk_queued", "tsk_wait", "tsk_done"]);
let reg = registry.lock().unwrap();
for task in session["result"]["tasks"].as_array().unwrap() {
let mut held =
serde_json::to_value(reg.get_task(task["id"].as_str().unwrap())).unwrap();
held.as_object_mut().unwrap().remove("artifacts");
assert_eq!(*task, held);
}
drop(reg);
let with = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"includeArtifacts": true}),
);
let tasks = with["result"]["tasks"].as_array().unwrap();
assert_eq!(tasks[0]["artifacts"], serde_json::json!([]), "{with}");
assert_eq!(tasks[1]["artifacts"][0]["artifactId"], "prompt", "{with}");
assert_eq!(tasks[2]["artifacts"][0]["artifactId"], "response", "{with}");
let first = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"pageSize": 2}),
);
assert_eq!(ids(&first), ["tsk_queued", "tsk_wait"]);
let token = first["result"]["nextPageToken"].as_str().unwrap();
assert!(!token.is_empty());
let second = list(
®istry,
&TaskCaller::Session,
serde_json::json!({"pageSize": 2, "pageToken": token}),
);
assert_eq!(ids(&second), ["tsk_done"]);
assert_eq!(second["result"]["nextPageToken"], "");
let foreign = list(
®istry,
&credential("member:planner"),
serde_json::json!({"pageSize": 2, "pageToken": token}),
);
assert_eq!(
ids(&foreign),
Vec::<String>::new(),
"a token is a position only"
);
}
#[test]
fn tenant_is_refused_unless_unset() {
let req = |params: Value| JsonRpcRequest {
id: serde_json::json!(1),
method: "GetTask".to_string(),
params,
};
for tenant in [
serde_json::json!("acme"),
serde_json::json!(7),
serde_json::json!(null),
serde_json::json!({}),
] {
let refused = tenant_refusal(&req(serde_json::json!({"tenant": tenant})))
.expect("a tenant is refused");
let answer = serde_json::to_value(&refused).unwrap();
assert_eq!(answer["error"]["code"], -32602, "{answer}");
assert_eq!(
answer["error"]["message"],
format!(
"Invalid params: tenant {tenant} names a tenant; this agent serves no \
tenants, so omit tenant"
)
);
}
assert!(tenant_refusal(&req(serde_json::json!({"tenant": ""}))).is_none());
assert!(tenant_refusal(&req(serde_json::json!({"id": "tsk_1"}))).is_none());
}
#[test]
fn task_caller_of_a_grant_is_the_session_only_for_the_operator() {
let auth = crate::door_auth::DoorAuth::mint(&murmur_artifact::NetworkAuthentication {
scheme: murmur_artifact::AuthenticationScheme::Bearer,
credentials: vec![murmur_artifact::DoorCredential {
name: "alice".to_string(),
scopes: vec!["ListTasks".to_string()],
}],
})
.unwrap();
let grant_of = |name: &str| {
let token = &auth.tokens().iter().find(|(n, _)| n == name).unwrap().1;
auth.verify(&[&crate::door_auth::bearer_header(token)])
.unwrap()
};
assert_eq!(TaskCaller::of(None), TaskCaller::Session);
assert_eq!(
TaskCaller::of(Some(&grant_of("operator"))),
TaskCaller::Session
);
assert_eq!(
TaskCaller::of(Some(&grant_of("alice"))),
credential("alice")
);
}
#[test]
fn a_completion_is_acknowledged_with_an_agent_message() {
let params = serde_json::json!({"message": {"contextId": "ctx_parent"}});
assert_eq!(
completion_received(¶ms, "dlg_1"),
serde_json::json!({
"messageId": "msg_dlg_1_received",
"contextId": "ctx_parent",
"role": "ROLE_AGENT",
"parts": [{"data": {"delegation_id": "dlg_1", "received": true},
"mediaType": "application/json"}],
})
);
assert!(completion_received(&serde_json::json!({}), "dlg_1")
.get("contextId")
.is_none());
let answer = serde_json::json!({ "message": completion_received(¶ms, "dlg_1") });
crate::a2a_conformance::check_message(&answer, "lf.a2a.v1.SendMessageResponse")
.unwrap_or_else(|errors| panic!("{answer}: {errors:?}"));
}
#[test]
fn door_method_served_methods_are_exactly_what_resolve_serves() {
for acceptance in &ACCEPTANCES {
for authenticated in [false, true] {
let served = served_methods(acceptance, authenticated);
for method in DoorMethod::ALL {
let resolved =
DoorMethod::resolve(method.wire_name(), acceptance, authenticated);
assert_eq!(
served.contains(&method.wire_name()),
resolved.is_some(),
"{} under {acceptance:?}, authenticated {authenticated}",
method.wire_name()
);
if let Some(resolved) = resolved {
assert_eq!(resolved, method, "{acceptance:?}");
}
}
let listed_in_all_order: Vec<&str> = DoorMethod::ALL
.into_iter()
.map(DoorMethod::wire_name)
.filter(|name| served.contains(name))
.collect();
assert_eq!(served, listed_in_all_order, "{acceptance:?}");
}
}
}
#[test]
fn door_method_acceptance_none_serves_no_task_starting_method() {
assert_eq!(
served_methods(&TaskAcceptance::None, false),
[
"murmur.WatchSession",
"GetTask",
"ListTasks",
"CancelTask",
"murmur.StopSession"
]
);
for acceptance in [TaskAcceptance::Single, TaskAcceptance::Queue] {
assert_eq!(
served_methods(&acceptance, false),
[
"SendMessage",
"SendStreamingMessage",
"murmur.WatchSession",
"GetTask",
"ListTasks",
"CancelTask",
"SubscribeToTask",
"murmur.StopSession"
]
);
}
}
#[test]
fn door_method_subscribe_streams_exactly_when_send_streaming_message_does() {
for acceptance in &ACCEPTANCES {
for authenticated in [false, true] {
let resolves = |method: DoorMethod| {
DoorMethod::resolve(method.wire_name(), acceptance, authenticated).is_some()
};
assert_eq!(
resolves(DoorMethod::SubscribeToTask),
resolves(DoorMethod::SendStreamingMessage),
"{acceptance:?}"
);
assert_eq!(
resolves(DoorMethod::ListTasks),
resolves(DoorMethod::GetTask),
"{acceptance:?}"
);
}
}
}
#[test]
fn door_method_push_config_methods_are_not_door_methods() {
for name in PUSH_CONFIG_METHODS {
for acceptance in &ACCEPTANCES {
for authenticated in [false, true] {
assert_eq!(DoorMethod::resolve(name, acceptance, authenticated), None);
assert!(!served_methods(acceptance, authenticated).contains(&name));
}
}
}
}
#[test]
fn door_method_only_an_authenticated_door_serves_the_extended_card() {
for acceptance in &ACCEPTANCES {
assert_eq!(
DoorMethod::resolve("GetExtendedAgentCard", acceptance, false),
None
);
assert_eq!(
DoorMethod::resolve("GetExtendedAgentCard", acceptance, true),
Some(DoorMethod::GetExtendedAgentCard)
);
let mut expected = served_methods(acceptance, false);
expected.push("GetExtendedAgentCard");
assert_eq!(served_methods(acceptance, true), expected);
}
}
#[test]
fn door_method_murmur_methods_are_namespaced() {
let rpcs = crate::a2a_conformance::v1_methods().expect("the vendored a2a.proto reads");
for method in DoorMethod::ALL {
let name = method.wire_name();
if rpcs.iter().any(|rpc| rpc.name == name) {
assert!(!name.contains('.'), "{name} is an A2A rpc");
continue;
}
let word = name.strip_prefix("murmur.").unwrap_or_else(|| {
panic!("{name} is not an A2A rpc, so it is named murmur.<Method>")
});
let mut chars = word.chars();
assert!(
chars.next().is_some_and(|c| c.is_ascii_uppercase())
&& chars.all(|c| c.is_ascii_alphanumeric()),
"{name}: murmur.<Method> takes an ASCII PascalCase word"
);
}
}
#[test]
fn door_method_resolve_matches_names_exactly() {
for acceptance in &ACCEPTANCES {
for name in [
"tasks/list",
"tasks/resubscribe",
"listTasks",
"subscribeToTask",
"",
"getTask",
"GETTASK",
"message/send",
"message/stream",
"tasks/get",
"tasks/cancel",
"agent/getAuthenticatedExtendedCard",
" GetTask",
"GetTask ",
"GetTask\n",
"stream/watch",
"session/stop",
"murmur.watchsession",
" murmur.StopSession",
"murmur.StopSession/",
"WatchSession",
"StopSession",
"murmur/StopSession",
] {
for authenticated in [false, true] {
assert_eq!(
DoorMethod::resolve(name, acceptance, authenticated),
None,
"{name:?} under {acceptance:?}"
);
}
}
}
}
#[test]
fn door_method_card_advertises_what_the_resolver_serves() {
for acceptance in &ACCEPTANCES {
let card = card_for(acceptance, DeclaredPlanes::default());
let methods = door_methods(&card);
assert_eq!(methods, served_methods(acceptance, false), "{acceptance:?}");
assert_eq!(
card["capabilities"]["streaming"],
methods.contains(&"SendStreamingMessage"),
"{acceptance:?}"
);
}
}
#[test]
fn door_method_card_lists_declared_planes_in_order() {
let cases = [
(false, false, serde_json::json!([])),
(true, false, serde_json::json!(["files"])),
(false, true, serde_json::json!(["peer_files"])),
(true, true, serde_json::json!(["files", "peer_files"])),
];
for (files, peer_files, expected) in cases {
let card = card_for(
&TaskAcceptance::Single,
DeclaredPlanes { files, peer_files },
);
assert_eq!(capsule_params(&card)["planes"], expected);
}
}
#[tokio::test]
async fn bind_local_port_os_assigned_when_none() {
let (listener, port) = bind_local_port("127.0.0.1", None).await.unwrap();
assert!(port > 0);
let err = bind_local_port("127.0.0.1", Some(port)).await.unwrap_err();
assert!(
matches!(err, RuntimeError::PortInUse { port: p } if p == port),
"expected PortInUse, got {err}"
);
drop(listener);
}
#[tokio::test]
async fn bind_local_port_uses_specified_port() {
let port = crate::pinned_port::claim();
let (_, bound_port) = bind_local_port("127.0.0.1", Some(port)).await.unwrap();
assert_eq!(bound_port, port);
}
#[tokio::test]
async fn bind_local_port_returns_port_in_use_when_taken() {
let (_listener, port) = bind_local_port("127.0.0.1", None).await.unwrap();
let err = bind_local_port("127.0.0.1", Some(port)).await.unwrap_err();
assert!(
matches!(err, RuntimeError::PortInUse { port: p } if p == port),
"expected PortInUse {{ port: {port} }}, got {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn watch_session_heartbeat_continues_while_session_thread_is_blocked() {
use std::time::{Duration, Instant};
const BLOCK: Duration = Duration::from_secs(20);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (client, line_rx) = spawn_sse_line_reader(addr, BLOCK + Duration::from_secs(10));
let (sse_tx, _sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let (_closing_tx, closing) = watch::channel(false);
let local = tokio::task::LocalSet::new();
let (connected_at, blocked_at, watcher) = local
.run_until(async {
let (sock, _peer) = listener.accept().await.unwrap();
let connected_at = Instant::now();
let (read_half, write_half) = sock.into_split();
let sse = sse_tx.clone();
let buffer = Arc::clone(&sse_buffer);
let watcher = tokio::spawn(async move {
let _read_half = read_half;
handle_watch_session(
write_half,
&serde_json::json!(1),
Some(0),
sse,
buffer,
"stateless".to_string(),
closing,
)
.await;
});
tokio::time::sleep(Duration::from_millis(250)).await;
let blocked_at = Instant::now();
tokio::task::spawn_local(async { std::thread::sleep(BLOCK) })
.await
.unwrap();
(connected_at, blocked_at, watcher)
})
.await;
watcher.abort();
let _ = watcher.await;
drop(local);
drop(sse_tx);
client.join().unwrap();
let released_at = blocked_at + BLOCK;
let heartbeats: Vec<Instant> = line_rx
.try_iter()
.filter(|(_, line)| line.trim_end() == ":heartbeat")
.map(|(at, _)| at)
.collect();
let arrivals: Vec<Duration> = heartbeats
.iter()
.map(|at| at.duration_since(connected_at))
.collect();
eprintln!(
"[heartbeat] thread held from {:?} to {:?} after connect; heartbeats arrived at {arrivals:?}",
blocked_at.duration_since(connected_at),
released_at.duration_since(connected_at)
);
let Some(&first) = heartbeats.first() else {
panic!("no heartbeat arrived while the session thread was blocked");
};
assert!(
first < released_at,
"the first heartbeat waited for the session thread: it arrived at {:?} after \
connect, and the thread was released at {:?} after connect",
first.duration_since(connected_at),
released_at.duration_since(connected_at)
);
assert!(
first.duration_since(connected_at) < SSE_HEARTBEAT_INTERVAL + Duration::from_secs(2),
"the tick due at {SSE_HEARTBEAT_INTERVAL:?} was late: the first heartbeat arrived at \
{:?} after connect",
first.duration_since(connected_at)
);
}
fn spawn_sse_line_reader(
addr: std::net::SocketAddr,
read_timeout: std::time::Duration,
) -> (
std::thread::JoinHandle<()>,
std::sync::mpsc::Receiver<(std::time::Instant, String)>,
) {
use std::io::{BufRead, BufReader};
let (line_tx, line_rx) = std::sync::mpsc::channel();
let client = std::thread::spawn(move || {
let sock = std::net::TcpStream::connect(addr).unwrap();
sock.set_read_timeout(Some(read_timeout)).unwrap();
let mut reader = BufReader::new(sock);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
if line_tx.send((std::time::Instant::now(), line)).is_err() {
break;
}
}
}
}
});
(client, line_rx)
}
async fn collect_sse_lines(
lines: &std::sync::mpsc::Receiver<(std::time::Instant, String)>,
received: &mut String,
needle: Option<&str>,
) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
loop {
match lines.try_recv() {
Ok((_, line)) => received.push_str(&line),
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
assert!(
needle.is_none_or(|n| received.contains(n)),
"the connection closed before {needle:?} arrived; received:\n{received}"
);
return;
}
}
}
if needle.is_some_and(|n| received.contains(n)) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for {needle:?}; received:\n{received}"
);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
async fn wait_for_subscriber(sse_tx: &SseBroadcast) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while sse_tx.receiver_count() != 1 {
assert!(
std::time::Instant::now() < deadline,
"the handler never subscribed"
);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
}
fn split_response(received: &str) -> (&str, &str) {
let at = received
.find("\r\n\r\n")
.unwrap_or_else(|| panic!("no header terminator in:\n{received}"));
(&received[..at + 2], &received[at + 4..])
}
fn sse_body(received: &str) -> &str {
split_response(received).1
}
fn events_of(body: &str) -> Vec<(Option<u64>, Value)> {
assert!(body.is_empty() || body.ends_with("\n\n"), "{body}");
body.split("\n\n")
.filter(|block| !block.is_empty() && *block != ":heartbeat")
.map(|block| {
let lines: Vec<&str> = block.lines().collect();
let (id, data) = match lines.as_slice() {
[data] => (None, *data),
[id, data] => (
Some(id.strip_prefix("id: ").unwrap().parse().unwrap()),
*data,
),
_ => panic!("an event is an optional id line and a data line: {block:?}"),
};
let data: Value = serde_json::from_str(data.strip_prefix("data: ").unwrap())
.unwrap_or_else(|e| panic!("data is not JSON ({e}): {block}"));
assert_eq!(data["jsonrpc"], "2.0", "{block}");
(id, data)
})
.collect()
}
fn stream_event(
id: u64,
task_id: &str,
murmur: bool,
ends: Option<StreamEnd>,
) -> Arc<StreamEvent> {
let result = format!("{{\"n\":{id}}}");
Arc::new(StreamEvent {
id,
task_id: task_id.to_string(),
plain: (!murmur).then(|| result.clone()),
extended: result,
state: (!murmur).then_some(TaskState::Working),
ends,
})
}
fn ns(events: &[(Option<u64>, Value)]) -> Vec<u64> {
events
.iter()
.filter_map(|(_, data)| data["result"]["n"].as_u64())
.collect()
}
fn stream_request(id: Value, params: Value) -> JsonRpcRequest {
JsonRpcRequest {
id,
method: "SendStreamingMessage".to_string(),
params,
}
}
async fn serve_message_stream(
req: JsonRpcRequest,
task_registry: Arc<Mutex<TaskRegistry>>,
task_tx: mpsc::Sender<IncomingTask>,
request: StreamRequest,
sse_tx: &SseBroadcast,
sse_buffer: &Arc<Mutex<SseEventBuffer>>,
closing: watch::Receiver<bool>,
) -> (
tokio::task::JoinHandle<()>,
std::sync::mpsc::Receiver<(std::time::Instant, String)>,
std::thread::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (client, lines) = spawn_sse_line_reader(
listener.local_addr().unwrap(),
std::time::Duration::from_secs(30),
);
let (sock, _peer) = listener.accept().await.unwrap();
let (read_half, write_half) = sock.into_split();
let sse = sse_tx.clone();
let buffer = Arc::clone(sse_buffer);
let handler = tokio::spawn(async move {
let _read_half = read_half;
handle_message_stream(
write_half,
req,
&task_registry,
&task_tx,
operator(),
false,
request,
sse,
buffer,
closing,
)
.await;
});
(handler, lines, client)
}
const PLAIN: StreamRequest = StreamRequest {
last_event_id: None,
activated: false,
};
const ACTIVATED: StreamRequest = StreamRequest {
last_event_id: None,
activated: true,
};
#[tokio::test]
async fn message_stream_refused_by_a_closed_registry_says_the_session_is_closing() {
for (close, expected) in [
(true, crate::a2a::REJECTED_SESSION_CLOSING_MESSAGE),
(false, crate::a2a::REJECTED_BUSY_MESSAGE),
] {
let (sse_tx, _sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(4);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(1, TaskAcceptance::Single)));
if close {
task_registry.lock().unwrap().close_to_new_work();
} else {
task_registry
.lock()
.unwrap()
.enqueue("tsk_busy", "ctx_busy");
}
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(serde_json::json!(1), message_send_params("msg_refused")),
task_registry,
task_tx,
PLAIN,
&sse_tx,
&sse_buffer,
closing,
)
.await;
handler.await.unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let (head, body) = split_response(&received);
assert!(head.contains("content-type: text/event-stream"), "{head}");
let events = events_of(body);
assert_eq!(events.len(), 1, "one event: {body}");
let (id, data) = &events[0];
assert_eq!(*id, None, "a refusal is unnumbered");
assert_eq!(data["id"], 1);
let task = &data["result"]["task"];
assert_eq!(task["status"]["state"], "TASK_STATE_REJECTED", "{data}");
assert_eq!(
task["status"]["message"]["parts"][0]["text"], expected,
"{data}"
);
assert!(
task_rx.try_recv().is_err(),
"nothing was handed to the loop"
);
assert!(
matches!(
sse_buffer.lock().unwrap().replay_from(0),
ReplayResult::Complete(frames) if frames.is_empty()
),
"a door refusal is never buffered"
);
}
}
#[tokio::test]
async fn message_stream_whose_hand_off_fails_is_refused_before_the_head() {
let (sse_tx, _sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(4);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, task_rx) = mpsc::channel::<IncomingTask>(4);
drop(task_rx);
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(serde_json::json!(5), message_send_params("msg_lost")),
Arc::clone(&task_registry),
task_tx,
ACTIVATED,
&sse_tx,
&sse_buffer,
closing,
)
.await;
handler.await.unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let (head, body) = split_response(&received);
assert!(head.contains("content-type: application/json"), "{head}");
assert!(!head.contains("text/event-stream"), "{head}");
let answer: Value = serde_json::from_str(body).unwrap();
assert_eq!(answer["id"], 5);
assert_eq!(answer["error"]["code"], INTERNAL_ERROR);
let reg = task_registry.lock().unwrap();
assert!(reg.history.is_empty(), "the enqueue was rolled back");
assert_eq!(reg.pending_count, 0);
}
#[tokio::test]
async fn message_stream_writes_only_its_own_tasks_standard_events() {
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(serde_json::json!("req-7"), message_send_params("msg_own")),
task_registry,
task_tx,
PLAIN,
&sse_tx,
&sse_buffer,
closing,
)
.await;
wait_for_subscriber(&sse_tx).await;
let task_id = task_rx.recv().await.unwrap().task_id;
for event in [
stream_event(1, &task_id, false, None),
stream_event(2, &task_id, true, None),
stream_event(3, "tsk_other", false, Some(StreamEnd::Terminal)),
stream_event(4, &task_id, false, Some(StreamEnd::Terminal)),
stream_event(5, &task_id, false, None),
] {
sse_tx.send(event).unwrap();
}
handler.await.unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let (head, body) = split_response(&received);
assert!(
!head.to_ascii_lowercase().contains("a2a-extensions"),
"{head}"
);
let events = events_of(body);
assert!(
events.iter().all(|(_, data)| data["id"] == "req-7"),
"{body}"
);
assert_eq!(events[0].0, None, "the opening task is unnumbered");
assert_eq!(events[0].1["result"]["task"]["id"], task_id.as_str());
assert_eq!(
events[0].1["result"]["task"]["status"]["state"],
"TASK_STATE_SUBMITTED"
);
assert_eq!(
events[1..].iter().map(|(id, _)| *id).collect::<Vec<_>>(),
[Some(1), Some(4)]
);
assert_eq!(ns(&events), [1, 4]);
}
fn lag_burst(task_id: &str, murmur: bool) -> Vec<Arc<StreamEvent>> {
(1..=(LAG_QUEUE + LAG_MISSED) as u64)
.map(|id| stream_event(id, task_id, murmur, None))
.collect()
}
const LAG_QUEUE: usize = 4;
const LAG_MISSED: usize = 3;
#[tokio::test]
async fn watch_session_writes_lagged_frame() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (client, lines) = spawn_sse_line_reader(
listener.local_addr().unwrap(),
std::time::Duration::from_secs(30),
);
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(LAG_QUEUE);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let (sock, _peer) = listener.accept().await.unwrap();
let (read_half, write_half) = sock.into_split();
let sse = sse_tx.clone();
let buffer = Arc::clone(&sse_buffer);
let (_closing_tx, closing) = watch::channel(false);
let watcher = tokio::spawn(async move {
let _read_half = read_half;
handle_watch_session(
write_half,
&serde_json::json!(1),
Some(0),
sse,
buffer,
"stateless".to_string(),
closing,
)
.await;
});
wait_for_subscriber(&sse_tx).await;
for event in lag_burst("tsk_a", true) {
sse_tx.send(event).unwrap();
}
let mut received = String::new();
collect_sse_lines(&lines, &mut received, Some("{\"n\":7}")).await;
sse_tx
.send(stream_event(100, "tsk_b", false, Some(StreamEnd::Terminal)))
.unwrap();
collect_sse_lines(&lines, &mut received, Some("{\"n\":100}")).await;
watcher.abort();
let _ = watcher.await;
drop(sse_tx);
client.join().unwrap();
let (head, body) = split_response(&received);
assert!(
head.contains(&format!(
"A2A-Extensions: {DOOR_EXTENSION_URI}, {STREAM_EXTENSION_URI}\r\n"
)),
"murmur.WatchSession always echoes both extensions: {head}"
);
let events = events_of(body);
let frames: Vec<Value> = events
.iter()
.map(|(_, data)| crate::streaming::frame_of(&data["result"]))
.collect();
assert_eq!(
frames[0],
serde_json::json!({"frame": "connection-ack", "role": "observer", "conversationMode": "stateless"})
);
assert_eq!(
frames[1],
serde_json::json!({"frame": "lagged", "missed": LAG_MISSED})
);
for (id, data) in &events[..2] {
assert_eq!(*id, None, "{data}");
assert_eq!(data["result"]["message"]["role"], "ROLE_AGENT", "{data}");
assert!(
data["result"]["message"].get("contextId").is_none(),
"{data}"
);
}
assert_eq!(ns(&events), [4, 5, 6, 7, 100]);
assert!(
matches!(
sse_buffer.lock().unwrap().replay_from(0),
ReplayResult::Complete(events) if events.is_empty()
),
"a lagged frame never enters the replay buffer"
);
}
#[tokio::test]
async fn message_stream_writes_lagged_frame() {
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(LAG_QUEUE);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(serde_json::json!(1), message_send_params("msg_lagged")),
task_registry,
task_tx,
ACTIVATED,
&sse_tx,
&sse_buffer,
closing,
)
.await;
wait_for_subscriber(&sse_tx).await;
let task_id = task_rx.recv().await.unwrap().task_id;
for event in lag_burst(&task_id, true) {
sse_tx.send(event).unwrap();
}
let mut received = String::new();
collect_sse_lines(&lines, &mut received, Some("{\"n\":7}")).await;
sse_tx
.send(stream_event(
100,
"tsk_other",
false,
Some(StreamEnd::Terminal),
))
.unwrap();
sse_tx
.send(stream_event(
101,
&task_id,
false,
Some(StreamEnd::Terminal),
))
.unwrap();
handler.await.unwrap();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let (head, body) = split_response(&received);
assert!(
head.contains(&format!("A2A-Extensions: {STREAM_EXTENSION_URI}\r\n")),
"{head}"
);
let events = events_of(body);
assert!(events[0].1["result"].get("task").is_some(), "{body}");
let (id, lagged) = &events[1];
assert_eq!(*id, None);
assert_eq!(
lagged["result"]["statusUpdate"]["taskId"],
task_id.as_str(),
"{lagged}"
);
crate::a2a::assert_bare_status(
&lagged["result"]["statusUpdate"]["status"],
"TASK_STATE_SUBMITTED",
);
assert_eq!(
crate::streaming::frame_of(&lagged["result"]),
serde_json::json!({"frame": "lagged", "missed": LAG_MISSED})
);
assert_eq!(ns(&events), [4, 5, 6, 7, 101]);
}
#[tokio::test]
async fn message_stream_that_lost_its_ending_writes_it_from_the_registry() {
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(LAG_QUEUE);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(serde_json::json!(1), message_send_params("msg_lost")),
Arc::clone(&task_registry),
task_tx,
PLAIN,
&sse_tx,
&sse_buffer,
closing,
)
.await;
wait_for_subscriber(&sse_tx).await;
let incoming = task_rx.recv().await.unwrap();
let task_id = incoming.task_id.clone();
{
let mut reg = task_registry.lock().unwrap();
reg.start_task(
task_id.clone(),
incoming.context_id.clone(),
crate::lanes::TaskLane::User,
);
reg.finish_task(TaskState::Completed);
reg.record_ending(&task_id, "task completed", Some("four"), false, &[], &[]);
}
let lost_at = std::time::Instant::now();
for event in lag_burst("tsk_other", false) {
sse_tx.send(event).unwrap();
}
handler.await.unwrap();
assert!(
lost_at.elapsed() <= SSE_HEARTBEAT_INTERVAL + std::time::Duration::from_secs(2),
"the ending was written within one heartbeat of the loss"
);
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let events = events_of(sse_body(&received));
assert_eq!(events.len(), 3, "{received}");
assert!(events.iter().all(|(id, _)| id.is_none()), "{received}");
let reported = task_registry.lock().unwrap().get_task(&task_id).unwrap();
assert_eq!(
events[1].1["result"]["artifactUpdate"]["artifact"],
serde_json::to_value(reported.artifact("response").unwrap()).unwrap()
);
assert_eq!(events[1].1["result"]["artifactUpdate"]["lastChunk"], true);
assert_eq!(
events[2].1["result"]["statusUpdate"]["status"],
serde_json::to_value(&reported.status).unwrap()
);
assert!(!received.contains(STREAM_EXTENSION_URI), "{received}");
}
#[tokio::test]
async fn message_stream_continuation_past_an_evicted_cursor_writes_a_gap_only_when_activated() {
for activated in [true, false] {
let (sse_tx, _sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(2)));
let sink = Some((sse_tx.clone(), Arc::clone(&sse_buffer)));
let other = StreamTask::new("tsk_other", "ctx_other");
for turn in 1..=3 {
crate::streaming::emit_status(
&sink,
&other,
TaskState::Working,
&format!("inference turn {turn}"),
None,
);
}
let (registry, _waiter) = routing_registry();
let mut ended = registry.lock().unwrap().get_task("tsk_wait").unwrap();
ended.status = crate::a2a::TaskStatus::of(TaskState::Failed);
crate::streaming::emit_task_ending(&sink, &ended);
let (task_tx, _task_rx) = mpsc::channel::<IncomingTask>(4);
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(
serde_json::json!(1),
serde_json::json!({"message": naming("tsk_wait", None, "main")}),
),
Arc::clone(®istry),
task_tx,
StreamRequest {
last_event_id: Some(0),
activated,
},
&sse_tx,
&sse_buffer,
closing,
)
.await;
handler.await.unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let events = events_of(sse_body(&received));
let opening = &events[0].1["result"]["task"];
assert_eq!(opening["id"], "tsk_wait");
assert_eq!(opening["status"]["state"], "TASK_STATE_WORKING");
let gaps: Vec<&(Option<u64>, Value)> = events
.iter()
.filter(|(_, data)| crate::streaming::frame_of(&data["result"])["frame"] == "gap")
.collect();
if activated {
assert_eq!(gaps.len(), 1, "{received}");
let (id, gap) = gaps[0];
assert_eq!(*id, None);
assert_eq!(gap["result"]["statusUpdate"]["taskId"], "tsk_wait");
assert_eq!(
crate::streaming::frame_of(&gap["result"]),
serde_json::json!({"frame": "gap", "firstAvailableId": 3})
);
} else {
assert!(gaps.is_empty(), "{received}");
assert!(!received.contains(STREAM_EXTENSION_URI), "{received}");
}
let last = events.last().unwrap();
assert_eq!(last.0, Some(4));
assert_eq!(
last.1["result"]["statusUpdate"]["status"]["state"],
"TASK_STATE_FAILED"
);
assert!(!received.contains("tsk_other"), "{received}");
}
}
#[tokio::test]
async fn message_stream_writes_queued_frames_before_the_door_closes() {
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let task_registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let (closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_message_stream(
stream_request(serde_json::json!(1), message_send_params("msg_closing")),
task_registry,
task_tx,
PLAIN,
&sse_tx,
&sse_buffer,
closing,
)
.await;
wait_for_subscriber(&sse_tx).await;
let task_id = task_rx.recv().await.unwrap().task_id;
sse_tx
.send(stream_event(1, &task_id, false, Some(StreamEnd::Terminal)))
.unwrap();
closing_tx.send(true).unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
handler.await.unwrap();
client.join().unwrap();
let events = events_of(sse_body(&received));
assert_eq!(events.len(), 2, "{received}");
assert_eq!(events[1].0, Some(1));
assert_eq!(ns(&events), [1]);
}
#[tokio::test]
async fn watch_session_writes_queued_frames_before_the_door_closes() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (client, lines) = spawn_sse_line_reader(
listener.local_addr().unwrap(),
std::time::Duration::from_secs(30),
);
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let (sock, _peer) = listener.accept().await.unwrap();
let (read_half, write_half) = sock.into_split();
let sse = sse_tx.clone();
let buffer = Arc::clone(&sse_buffer);
let (closing_tx, closing) = watch::channel(false);
let watcher = tokio::spawn(async move {
let _read_half = read_half;
handle_watch_session(
write_half,
&serde_json::json!("watch-1"),
Some(0),
sse,
buffer,
"stateless".to_string(),
closing,
)
.await;
});
wait_for_subscriber(&sse_tx).await;
sse_tx
.send(stream_event(1, "tsk_closing", true, None))
.unwrap();
sse_tx
.send(stream_event(
2,
"tsk_closing",
false,
Some(StreamEnd::Terminal),
))
.unwrap();
closing_tx.send(true).unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
watcher.await.unwrap();
client.join().unwrap();
let events = events_of(sse_body(&received));
assert!(events.iter().all(|(_, data)| data["id"] == "watch-1"));
assert_eq!(
events.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
[None, Some(1), Some(2)]
);
assert_eq!(
crate::streaming::frame_of(&events[0].1["result"])["frame"],
"connection-ack"
);
assert!(!received.contains("capsule-closed"), "{received}");
}
#[tokio::test]
async fn watch_session_writes_capsule_closed_when_the_broadcast_closes() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (client, lines) = spawn_sse_line_reader(
listener.local_addr().unwrap(),
std::time::Duration::from_secs(30),
);
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let (sock, _peer) = listener.accept().await.unwrap();
let (read_half, write_half) = sock.into_split();
let sse = sse_tx.clone();
let (_closing_tx, closing) = watch::channel(false);
let watcher = tokio::spawn(async move {
let _read_half = read_half;
handle_watch_session(
write_half,
&serde_json::json!(1),
None,
sse,
sse_buffer,
"threaded".to_string(),
closing,
)
.await;
});
wait_for_subscriber(&sse_tx).await;
drop(sse_tx);
watcher.await.unwrap();
let mut received = String::new();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let frames: Vec<Value> = events_of(sse_body(&received))
.iter()
.map(|(_, data)| crate::streaming::frame_of(&data["result"]))
.collect();
assert_eq!(
frames,
[
serde_json::json!({"frame": "connection-ack", "role": "observer", "conversationMode": "threaded"}),
serde_json::json!({"frame": "capsule-closed"}),
]
);
}
const PROTOCOL_PAGE_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/content/reference/streaming-protocol.md"
);
async fn serve_subscription(
task_id: &str,
task_registry: Arc<Mutex<TaskRegistry>>,
caller: TaskCaller,
sse_tx: &SseBroadcast,
sse_buffer: &Arc<Mutex<SseEventBuffer>>,
closing: watch::Receiver<bool>,
) -> (
tokio::task::JoinHandle<()>,
std::sync::mpsc::Receiver<(std::time::Instant, String)>,
std::thread::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (client, lines) = spawn_sse_line_reader(
listener.local_addr().unwrap(),
std::time::Duration::from_secs(60),
);
let (sock, _peer) = listener.accept().await.unwrap();
let (read_half, write_half) = sock.into_split();
let sse = sse_tx.clone();
let buffer = Arc::clone(sse_buffer);
let req = JsonRpcRequest {
id: serde_json::json!("sub-1"),
method: "SubscribeToTask".to_string(),
params: serde_json::json!({"id": task_id}),
};
let handler = tokio::spawn(async move {
let _read_half = read_half;
handle_task_subscribe(
write_half,
req,
&task_registry,
&caller,
PLAIN,
sse,
buffer,
closing,
)
.await;
});
(handler, lines, client)
}
fn running_task_registry() -> Arc<Mutex<TaskRegistry>> {
let mut reg = TaskRegistry::new(4, TaskAcceptance::Queue);
reg.enqueue("tsk_run", "ctx_run");
reg.start_task(
"tsk_run".to_string(),
"ctx_run".to_string(),
crate::lanes::TaskLane::User,
);
Arc::new(Mutex::new(reg))
}
#[test]
fn subscribe_refusals_are_errors_before_any_stream() {
let registry = running_task_registry();
{
let mut reg = registry.lock().unwrap();
reg.enqueue("tsk_alice", "ctx_a");
reg.record_owner("tsk_alice", "alice");
reg.enqueue("tsk_done", "ctx_done");
reg.request_cancel("tsk_done");
}
let refusal = |params: Value, caller: &TaskCaller| {
let req = JsonRpcRequest {
id: serde_json::json!(1),
method: "SubscribeToTask".to_string(),
params,
};
let response = subscribable_task(&req, ®istry, caller).unwrap_err();
serde_json::to_value(&response).unwrap()
};
for params in [
serde_json::json!({}),
serde_json::json!({"id": ""}),
serde_json::json!({"id": 7}),
] {
let answer = refusal(params, &TaskCaller::Session);
assert_eq!(answer["error"]["code"], -32602, "{answer}");
assert_eq!(answer["error"]["message"], "SubscribeToTask requires an id");
}
let never = refusal(
serde_json::json!({"id": "tsk_never_held"}),
&credential("bob"),
);
for hidden in ["tsk_run", "tsk_alice"] {
let answer = refusal(serde_json::json!({"id": hidden}), &credential("bob"));
assert_eq!(answer["error"]["code"], -32001, "{answer}");
let mut as_never = never.clone();
as_never["error"]["data"][0]["metadata"]["taskId"] = Value::from(hidden);
assert_eq!(answer, as_never, "a hidden task reads as one never held");
}
let ended = refusal(serde_json::json!({"id": "tsk_done"}), &TaskCaller::Session);
assert_eq!(ended["error"]["code"], -32004, "{ended}");
assert_eq!(
ended["error"]["data"][0]["metadata"],
serde_json::json!({"taskId": "tsk_done", "state": "TASK_STATE_CANCELED"})
);
let req = |id: &str| JsonRpcRequest {
id: serde_json::json!(1),
method: "SubscribeToTask".to_string(),
params: serde_json::json!({"id": id}),
};
assert!(subscribable_task(&req("tsk_alice"), ®istry, &credential("alice")).is_ok());
assert!(subscribable_task(&req("tsk_run"), ®istry, &TaskCaller::Session).is_ok());
}
#[tokio::test]
async fn subscribe_writes_through_input_required_to_the_terminal_status() {
let (sse_tx, _sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(8);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let registry = running_task_registry();
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_subscription(
"tsk_run",
Arc::clone(®istry),
TaskCaller::Session,
&sse_tx,
&sse_buffer,
closing,
)
.await;
let mut received = String::new();
collect_sse_lines(&lines, &mut received, Some("TASK_STATE_WORKING")).await;
sse_tx
.send(stream_event(
1,
"tsk_run",
false,
Some(StreamEnd::Interrupted),
))
.unwrap();
sse_tx
.send(stream_event(
2,
"tsk_other",
false,
Some(StreamEnd::Terminal),
))
.unwrap();
sse_tx
.send(stream_event(3, "tsk_run", false, Some(StreamEnd::Terminal)))
.unwrap();
handler.await.unwrap();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let events = events_of(sse_body(&received));
assert_eq!(ns(&events), [1, 3], "{received}");
assert_eq!(events[0].0, None, "the opening task is unnumbered");
let opening = registry.lock().unwrap().get_task("tsk_run").unwrap();
assert_eq!(
events[0].1,
serde_json::json!({"jsonrpc": "2.0", "id": "sub-1",
"result": {"task": serde_json::to_value(&opening).unwrap()}})
);
assert!(events.iter().all(|(_, event)| event["id"] == "sub-1"));
}
#[tokio::test]
async fn subscribe_that_lagged_ends_with_the_terminal_status_from_the_registry() {
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(1);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let registry = running_task_registry();
let (_closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_subscription(
"tsk_run",
Arc::clone(®istry),
TaskCaller::Session,
&sse_tx,
&sse_buffer,
closing,
)
.await;
let mut received = String::new();
collect_sse_lines(&lines, &mut received, Some("TASK_STATE_WORKING")).await;
{
let mut reg = registry.lock().unwrap();
reg.finish_task(TaskState::Completed);
reg.record_ending("tsk_run", "task completed", Some("four"), false, &[], &[]);
}
let lost_at = std::time::Instant::now();
for id in 1..=3 {
sse_tx
.send(stream_event(id, "tsk_other", false, None))
.unwrap();
}
handler.await.unwrap();
assert!(
lost_at.elapsed() <= SSE_HEARTBEAT_INTERVAL + std::time::Duration::from_secs(2),
"the ending was written within one heartbeat of the loss"
);
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
let events = events_of(sse_body(&received));
assert_eq!(events.len(), 3, "{received}");
assert!(events.iter().all(|(id, _)| id.is_none()), "{received}");
let reported = registry.lock().unwrap().get_task("tsk_run").unwrap();
assert_eq!(
events[1].1["result"]["artifactUpdate"]["artifact"],
serde_json::to_value(reported.artifact("response").unwrap()).unwrap()
);
assert_eq!(
events[2].1["result"]["statusUpdate"]["status"],
serde_json::to_value(&reported.status).unwrap()
);
}
#[tokio::test]
async fn subscribe_that_lagged_on_an_input_required_task_stays_open() {
let (sse_tx, sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(1);
drop(sse_rx);
let sse_buffer = Arc::new(Mutex::new(SseEventBuffer::new(8)));
let registry = running_task_registry();
let (_waiter_tx, waiter_rx) = oneshot::channel();
registry
.lock()
.unwrap()
.set_input_required("tsk_run", "which branch?".to_string(), _waiter_tx)
.unwrap();
let (closing_tx, closing) = watch::channel(false);
let (handler, lines, client) = serve_subscription(
"tsk_run",
Arc::clone(®istry),
TaskCaller::Session,
&sse_tx,
&sse_buffer,
closing,
)
.await;
let mut received = String::new();
collect_sse_lines(&lines, &mut received, Some("TASK_STATE_INPUT_REQUIRED")).await;
for id in 1..=3 {
sse_tx
.send(stream_event(id, "tsk_other", false, None))
.unwrap();
}
tokio::time::sleep(SSE_HEARTBEAT_INTERVAL + std::time::Duration::from_secs(2)).await;
assert!(
!handler.is_finished(),
"a lagged subscription ended on input-required"
);
closing_tx.send(true).unwrap();
handler.await.unwrap();
collect_sse_lines(&lines, &mut received, None).await;
client.join().unwrap();
drop(waiter_rx);
let events = events_of(sse_body(&received));
assert_eq!(events.len(), 1, "only the opening task: {received}");
assert!(received.contains(":heartbeat"), "{received}");
}
fn protocol_page() -> String {
std::fs::read_to_string(PROTOCOL_PAGE_PATH)
.unwrap_or_else(|e| panic!("cannot read {PROTOCOL_PAGE_PATH}: {e}"))
}
fn protocol_page_section<'a>(page: &'a str, anchor: &str) -> &'a str {
let marker = format!("{{ #{anchor} }}");
let at = page
.find(&marker)
.unwrap_or_else(|| panic!("{PROTOCOL_PAGE_PATH} has no heading with anchor {marker}"));
let start = page[..at].rfind('\n').map_or(0, |i| i + 1);
let len = page[start..].find("\n---\n").unwrap_or_else(|| {
panic!("the {marker} section of {PROTOCOL_PAGE_PATH} has no closing `---` line")
});
&page[start..start + len]
}
fn numbers_before_seconds(text: &str) -> Vec<u64> {
let words: Vec<&str> = text.split_whitespace().collect();
words
.windows(2)
.filter(|pair| {
pair[1]
.trim_end_matches(|c: char| !c.is_alphanumeric())
.eq("seconds")
})
.filter_map(|pair| {
pair[0]
.trim_start_matches(|c: char| !c.is_ascii_digit())
.parse()
.ok()
})
.collect()
}
#[test]
fn protocol_page_heartbeat_interval_is_the_runtime_interval() {
let page = protocol_page();
let section = protocol_page_section(&page, "heartbeat");
let interval = SSE_HEARTBEAT_INTERVAL.as_secs();
let row = format!("| Interval | {interval} seconds");
assert!(
section.contains(&row),
"the Heartbeat section ({{ #heartbeat }}) of {PROTOCOL_PAGE_PATH} has no `{row}` row; \
SSE_HEARTBEAT_INTERVAL is {interval} seconds"
);
let stated = numbers_before_seconds(section);
let wrong: Vec<u64> = stated.iter().copied().filter(|&n| n != interval).collect();
assert!(
wrong.is_empty(),
"the Heartbeat section ({{ #heartbeat }}) of {PROTOCOL_PAGE_PATH} states {wrong:?} \
seconds; SSE_HEARTBEAT_INTERVAL is {interval} seconds"
);
}
#[test]
fn protocol_page_broadcast_queue_is_the_runtime_capacity() {
let page = protocol_page();
let section = protocol_page_section(&page, "lagged");
let capacity = crate::runtime::SSE_BROADCAST_CAPACITY;
for phrase in [
format!("queue of {capacity}"),
format!("more than {capacity}"),
] {
assert!(
section.contains(&phrase),
"the Events a connection misses section ({{ #lagged }}) of {PROTOCOL_PAGE_PATH} \
does not say `{phrase}`; SSE_BROADCAST_CAPACITY is {capacity}"
);
}
}
#[test]
fn protocol_page_documents_the_lagged_frame() {
let page = protocol_page();
let section = protocol_page_section(&page, "event-lagged");
for required in ["`missed`", "\"frame\":\"lagged\",\"missed\":"] {
assert!(
section.contains(required),
"the `lagged` section ({{ #event-lagged }}) of {PROTOCOL_PAGE_PATH} does not \
contain `{required}`"
);
}
let row = "| [`lagged`](#event-lagged) | yes | yes |";
assert!(
page.contains(row),
"{PROTOCOL_PAGE_PATH} has no `{row}` row"
);
assert!(
protocol_page_section(&page, "event-ids")
.lines()
.any(|line| line.contains("`lagged`") && line.ends_with("| no |")),
"the Event ids section of {PROTOCOL_PAGE_PATH} does not list `lagged` as unnumbered"
);
assert!(
protocol_page_section(&page, "replay").contains("`lagged`"),
"the Replay section ({{ #replay }}) of {PROTOCOL_PAGE_PATH} does not list `lagged`"
);
assert!(
!protocol_page_section(&page, "lagged").contains("Nothing is written in their place"),
"the Events a connection misses section ({{ #lagged }}) of {PROTOCOL_PAGE_PATH} says \
nothing is written for lost frames"
);
}
#[test]
fn protocol_page_first_event_id_is_the_runtimes() {
let page = protocol_page();
let (tx, _rx) = tokio::sync::broadcast::channel(1);
let buffer = std::sync::Arc::new(Mutex::new(SseEventBuffer::new(1)));
crate::streaming::emit_status(
&Some((tx, Arc::clone(&buffer))),
&StreamTask::new("tsk_1", "ctx_1"),
TaskState::Working,
"inference turn 1",
None,
);
let first = match buffer.lock().unwrap().replay_from(0) {
ReplayResult::Complete(events) => events[0].id,
ReplayResult::WithGap { .. } => panic!("one event fits"),
};
let phrase = format!("| First id | `{first}` |");
assert!(
protocol_page_section(&page, "event-ids").contains(&phrase),
"the Event ids section ({{ #event-ids }}) of {PROTOCOL_PAGE_PATH} does not say \
`{phrase}`; a session's first frame takes id {first}"
);
let phrase = format!("starting at id `{first}`");
assert!(
protocol_page_section(&page, "replay").contains(&phrase),
"the Replay section ({{ #replay }}) of {PROTOCOL_PAGE_PATH} does not say `{phrase}`"
);
}
#[test]
fn protocol_page_replay_capacity_is_the_runtime_capacity() {
let page = protocol_page();
let section = protocol_page_section(&page, "replay");
let capacity = crate::runtime::SSE_REPLAY_CAPACITY;
let phrase = format!("most recent {capacity} events");
assert!(
section.contains(&phrase),
"the Replay section ({{ #replay }}) of {PROTOCOL_PAGE_PATH} does not say `{phrase}`; \
SSE_REPLAY_CAPACITY is {capacity}"
);
}
#[test]
fn protocol_page_says_the_heartbeat_keeps_its_cadence_during_a_turn() {
let page = protocol_page();
let section = protocol_page_section(&page, "heartbeat");
let required = "keeps its cadence while a turn is running";
assert!(
section.contains(required),
"the Heartbeat section ({{ #heartbeat }}) of {PROTOCOL_PAGE_PATH} does not say \
`{required}`"
);
for forbidden in ["same thread", "holds the heartbeat"] {
assert!(
!section.contains(forbidden),
"the Heartbeat section ({{ #heartbeat }}) of {PROTOCOL_PAGE_PATH} says \
`{forbidden}`, but the heartbeat is served off the agent loop's thread"
);
}
}
async fn post_completion(addr: &str, delegation_id: &str) -> Result<(), String> {
let handle = crate::delegation::SpawnerHandle {
session_id: "ses_door".to_string(),
context_id: "ctx_parent".to_string(),
delegation_id: delegation_id.to_string(),
report_to: Some(crate::delegation::CompletionAddress {
url: format!("http://{addr}"),
}),
};
let outcome = crate::delegation::DelegationOutcome {
delegation_id: delegation_id.to_string(),
capsule_name: "worker".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_child".to_string(),
status: crate::delegation::DelegationStatus::Ok,
result_path: None,
workdir: "/tmp/child".to_string(),
duration_ms: 1,
detail: None,
reported_by: crate::delegation::Reporter::Child,
delivered: false,
delivery_error: None,
};
tokio::task::spawn_blocking(move || {
let address = handle.report_to.clone().unwrap();
crate::delegation::deliver_completion(&handle, &address, &outcome)
})
.await
.unwrap()
}
fn outstanding(task_id: &str) -> crate::cancel::LiveDelegation {
crate::cancel::LiveDelegation {
task_id: task_id.to_string(),
workdir: std::path::PathBuf::from("/tmp/child"),
capsule: "worker".to_string(),
version: "0.1.0".to_string(),
child_session_id: "ses_child".to_string(),
child_workdir: "/home/test/.murmur/records/work/ses_parent/children/worker-1"
.to_string(),
child_records_dir: std::path::PathBuf::from(
"/home/test/.murmur/records/work/ses_child",
),
started: std::time::Instant::now(),
child: None,
arrived: false,
}
}
#[tokio::test]
async fn a_completion_is_handed_to_the_delegation_set_and_never_becomes_a_task() {
for acceptance in ACCEPTANCES {
let registry = Arc::new(Mutex::new(TaskRegistry::new(1, acceptance.clone())));
{
let mut reg = registry.lock().unwrap();
reg.enqueue("tsk_running", "ctx_parent");
reg.start_task(
"tsk_running".to_string(),
"ctx_parent".to_string(),
crate::lanes::TaskLane::User,
);
}
let live = Arc::new(LiveDelegations::new());
let _scope = live.scope_task("tsk_running");
assert!(live.register("dlg_waited".to_string(), outstanding("tsk_running")));
let (addr, _shutdown, mut task_rx) = serve_test_door_with(
acceptance.clone(),
Arc::clone(®istry),
Arc::clone(&live),
false,
)
.await;
post_completion(&addr, "dlg_waited")
.await
.unwrap_or_else(|error| panic!("{acceptance:?}: {error}"));
assert_eq!(live.counts(), (0, 1), "{acceptance:?}");
post_completion(&addr, "dlg_waited")
.await
.unwrap_or_else(|error| panic!("{acceptance:?}: a repeat: {error}"));
let refused = post_completion(&addr, "dlg_nobody")
.await
.expect_err("nobody is waiting for it");
assert!(
refused.contains("dlg_nobody") && refused.contains("no task"),
"{acceptance:?}: {refused}"
);
assert!(
task_rx.try_recv().is_err(),
"{acceptance:?}: a completion started a task"
);
assert_eq!(live.take_arrived().len(), 1, "{acceptance:?}");
}
}
#[tokio::test]
async fn a_completion_for_another_session_is_refused_before_the_delegation_set() {
let live = Arc::new(LiveDelegations::new());
let _scope = live.scope_task("tsk_running");
live.register("dlg_waited".to_string(), outstanding("tsk_running"));
let (addr, _shutdown, mut task_rx) = serve_test_door_with(
TaskAcceptance::Queue,
Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue))),
Arc::clone(&live),
false,
)
.await;
let handle = crate::delegation::SpawnerHandle {
session_id: "ses_elsewhere".to_string(),
context_id: "ctx_parent".to_string(),
delegation_id: "dlg_waited".to_string(),
report_to: Some(crate::delegation::CompletionAddress {
url: format!("http://{addr}"),
}),
};
let refused = tokio::task::spawn_blocking(move || {
let address = handle.report_to.clone().unwrap();
let outcome = crate::delegation::DelegationOutcome {
delegation_id: "dlg_waited".to_string(),
capsule_name: "worker".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_child".to_string(),
status: crate::delegation::DelegationStatus::Ok,
result_path: None,
workdir: "/tmp/child".to_string(),
duration_ms: 1,
detail: None,
reported_by: crate::delegation::Reporter::Child,
delivered: false,
delivery_error: None,
};
crate::delegation::deliver_completion(&handle, &address, &outcome)
})
.await
.unwrap()
.expect_err("addressed to another session");
assert!(refused.contains("ses_elsewhere"), "{refused}");
assert_eq!(live.counts(), (1, 0));
assert!(task_rx.try_recv().is_err());
}
async fn completion_answer(addr: &str, headers: &[(&'static str, &'static str)]) -> Value {
let url = format!("http://{addr}/");
let mut sent = vec![
(
crate::a2a::A2A_VERSION_HEADER,
crate::a2a::A2A_PROTOCOL_VERSION,
),
(PEER_ORIGIN_HEADER, "completion"),
];
sent.extend_from_slice(headers);
let body = serde_json::json!({"jsonrpc": "2.0", "id": "c1", "method": "SendMessage",
"params": {"message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "done"}]}}})
.to_string();
tokio::task::spawn_blocking(move || {
crate::http_client::http_json("POST", &url, Some(&body), &sent).unwrap()
})
.await
.unwrap()
}
#[tokio::test]
async fn a_completion_is_refused_with_the_murmur_completion_codes() {
let live = Arc::new(LiveDelegations::new());
let _scope = live.scope_task("tsk_running");
live.register("dlg_waited".to_string(), outstanding("tsk_running"));
let (addr, _shutdown, mut task_rx) = serve_test_door_with(
TaskAcceptance::Queue,
Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue))),
Arc::clone(&live),
false,
)
.await;
let info = |answer: &Value| answer["error"]["data"][0].clone();
let misaddressed = completion_answer(
&addr,
&[
(COMPLETION_SESSION_HEADER, "ses_elsewhere"),
(DELEGATION_ID_HEADER, "dlg_waited"),
],
)
.await;
assert_eq!(misaddressed["id"], "c1");
assert_eq!(misaddressed["error"]["code"], -31001, "{misaddressed}");
assert_eq!(info(&misaddressed)["reason"], "COMPLETION_MISADDRESSED");
assert_eq!(info(&misaddressed)["domain"], "murmur.nexus");
assert_eq!(
info(&misaddressed)["metadata"],
serde_json::json!({"addressedSession": "ses_elsewhere"})
);
assert!(
!misaddressed.to_string().contains("ses_door"),
"{misaddressed}"
);
let unaddressed = completion_answer(&addr, &[(DELEGATION_ID_HEADER, "dlg_waited")]).await;
assert_eq!(unaddressed["error"]["code"], -31001, "{unaddressed}");
assert_eq!(
info(&unaddressed)["metadata"],
serde_json::json!({"addressedSession": ""})
);
let unnamed = completion_answer(&addr, &[(COMPLETION_SESSION_HEADER, "ses_door")]).await;
assert_eq!(unnamed["error"]["code"], -31002, "{unnamed}");
assert_eq!(info(&unnamed)["reason"], "COMPLETION_NOT_AWAITED");
assert_eq!(info(&unnamed)["metadata"], serde_json::json!({}));
let unknown = completion_answer(
&addr,
&[
(COMPLETION_SESSION_HEADER, "ses_door"),
(DELEGATION_ID_HEADER, "dlg_nobody"),
],
)
.await;
assert_eq!(unknown["error"]["code"], -31002, "{unknown}");
assert_eq!(
info(&unknown)["metadata"],
serde_json::json!({"delegationId": "dlg_nobody"})
);
assert_eq!(live.counts(), (1, 0), "no refusal touched the delegation");
let delivered = completion_answer(
&addr,
&[
(COMPLETION_SESSION_HEADER, "ses_door"),
(DELEGATION_ID_HEADER, "dlg_waited"),
],
)
.await;
assert_eq!(
delivered["result"]["message"]["parts"][0]["data"],
serde_json::json!({"delegation_id": "dlg_waited", "received": true})
);
assert_eq!(delivered["result"]["message"]["role"], "ROLE_AGENT");
assert_eq!(live.counts(), (0, 1));
assert!(task_rx.try_recv().is_err(), "a completion started a task");
}
fn post_send_message(
addr: &str,
params: Value,
) -> std::thread::JoinHandle<(std::time::Instant, String)> {
use std::io::{Read, Write};
let body = serde_json::json!({
"jsonrpc": "2.0", "id": 7, "method": "SendMessage", "params": params,
})
.to_string();
let addr = addr.to_string();
std::thread::spawn(move || {
let mut stream = std::net::TcpStream::connect(&addr).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.unwrap();
write!(
stream,
"POST / HTTP/1.1\r\nHost: {addr}\r\ncontent-type: application/json\r\n\
A2A-Version: 1.0\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
)
.unwrap();
let mut answer = String::new();
stream.read_to_string(&mut answer).unwrap();
(std::time::Instant::now(), answer)
})
}
async fn held_send_message_door() -> (
Arc<Mutex<TaskRegistry>>,
IncomingTask,
oneshot::Sender<()>,
mpsc::Receiver<IncomingTask>,
std::thread::JoinHandle<(std::time::Instant, String)>,
) {
let registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (addr, shutdown, mut task_rx) = serve_test_door_with(
TaskAcceptance::Queue,
Arc::clone(®istry),
Arc::new(LiveDelegations::new()),
false,
)
.await;
let client = post_send_message(&addr, message_send_params("msg_held"));
let incoming = tokio::time::timeout(std::time::Duration::from_secs(10), task_rx.recv())
.await
.expect("the task reached the loop")
.unwrap();
(registry, incoming, shutdown, task_rx, client)
}
async fn answered_within(
client: &std::thread::JoinHandle<(std::time::Instant, String)>,
wait: std::time::Duration,
) -> bool {
let deadline = std::time::Instant::now() + wait;
while std::time::Instant::now() < deadline {
if client.is_finished() {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
client.is_finished()
}
#[tokio::test(flavor = "multi_thread")]
async fn held_send_message_answers_the_registry_s_task_when_the_door_closes() {
let (registry, incoming, shutdown, _task_rx, client) = held_send_message_door().await;
assert!(
!answered_within(&client, std::time::Duration::from_millis(300)).await,
"a task that has not settled is not answered"
);
let closed_at = std::time::Instant::now();
drop(shutdown);
let (answered_at, answer) = client.join().unwrap();
assert!(
answered_at.duration_since(closed_at) < CONNECTION_DRAIN_GRACE,
"answered {:?} after the door closed",
answered_at.duration_since(closed_at)
);
let task = &response_json(&answer)["result"]["task"];
assert_eq!(task["status"]["state"], "TASK_STATE_SUBMITTED", "{answer}");
let reported = registry
.lock()
.unwrap()
.get_task(&incoming.task_id)
.unwrap();
assert_eq!(*task, serde_json::to_value(&reported).unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn held_send_message_answers_once_the_ending_is_recorded() {
let (registry, incoming, _shutdown, _task_rx, client) = held_send_message_door().await;
let task_id = incoming.task_id.clone();
{
let mut reg = registry.lock().unwrap();
reg.start_task(
task_id.clone(),
incoming.context_id.clone(),
crate::lanes::TaskLane::User,
);
reg.finish_task(TaskState::Completed);
}
assert!(
!answered_within(&client, HELD_SEND_RECHECK_INTERVAL * 2).await,
"a terminal task with no ending is not answered"
);
registry
.lock()
.unwrap()
.record_ending(&task_id, "done", Some("four"), false, &[], &[]);
let (_, answer) = client.join().unwrap();
let task = &response_json(&answer)["result"]["task"];
assert_eq!(task["status"]["state"], "TASK_STATE_COMPLETED", "{answer}");
let reported = registry.lock().unwrap().get_task(&task_id).unwrap();
assert!(reported.artifact(crate::a2a::RESPONSE_ARTIFACT).is_some());
assert_eq!(*task, serde_json::to_value(&reported).unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn held_send_message_answers_a_task_waiting_for_input() {
let (registry, incoming, _shutdown, _task_rx, client) = held_send_message_door().await;
let (waiter, _input) = oneshot::channel();
{
let mut reg = registry.lock().unwrap();
reg.start_task(
incoming.task_id.clone(),
incoming.context_id.clone(),
crate::lanes::TaskLane::User,
);
reg.set_input_required(&incoming.task_id, "which branch?".to_string(), waiter)
.unwrap();
}
let (_, answer) = client.join().unwrap();
let task = &response_json(&answer)["result"]["task"];
assert_eq!(
task["status"]["state"], "TASK_STATE_INPUT_REQUIRED",
"{answer}"
);
let reported = registry
.lock()
.unwrap()
.get_task(&incoming.task_id)
.unwrap();
assert_eq!(
reported
.artifact(crate::a2a::PROMPT_ARTIFACT)
.and_then(crate::a2a::A2aArtifact::first_text),
Some("which branch?")
);
assert_eq!(*task, serde_json::to_value(&reported).unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn held_send_message_answers_on_its_task_s_event() {
let (sse_tx, _sse_rx) = tokio::sync::broadcast::channel::<Arc<StreamEvent>>(16);
let registry = Arc::new(Mutex::new(TaskRegistry::new(4, TaskAcceptance::Queue)));
let (task_tx, mut task_rx) = mpsc::channel::<IncomingTask>(4);
let (_closing_tx, closing) = watch::channel(false);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let client = post_send_message(
&listener.local_addr().unwrap().to_string(),
message_send_params("msg_held"),
);
let (sock, _peer) = listener.accept().await.unwrap();
let (read_half, write_half) = sock.into_split();
let handler = {
let (registry, sse) = (Arc::clone(®istry), sse_tx.clone());
tokio::spawn(async move {
let _read_half = read_half;
handle_message_send(
write_half,
JsonRpcRequest {
id: serde_json::json!(7),
method: "SendMessage".to_string(),
params: message_send_params("msg_held"),
},
®istry,
&task_tx,
operator(),
false,
sse,
closing,
)
.await;
})
};
let incoming = task_rx.recv().await.unwrap();
let task_id = incoming.task_id.clone();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
{
let mut reg = registry.lock().unwrap();
reg.start_task(
task_id.clone(),
incoming.context_id.clone(),
crate::lanes::TaskLane::User,
);
reg.finish_task(TaskState::Failed);
reg.record_ending(&task_id, "failed", None, false, &[], &[]);
}
sse_tx
.send(stream_event(
1,
"tsk_other",
false,
Some(StreamEnd::Terminal),
))
.unwrap();
assert!(
!answered_within(&client, std::time::Duration::from_millis(200)).await,
"another task's event answered the held request"
);
let ended_at = std::time::Instant::now();
sse_tx
.send(stream_event(1, &task_id, false, Some(StreamEnd::Terminal)))
.unwrap();
handler.await.unwrap();
let (answered_at, answer) = client.join().unwrap();
assert!(
answered_at.duration_since(ended_at) < HELD_SEND_RECHECK_INTERVAL / 2,
"answered {:?} after the event",
answered_at.duration_since(ended_at)
);
assert_eq!(
response_json(&answer)["result"]["task"]["status"]["state"],
"TASK_STATE_FAILED",
"{answer}"
);
}
}