#![cfg(all(feature = "net", feature = "cortex", feature = "tool"))]
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use net::adapter::net::behavior::capability::{CapabilityFilter, CapabilitySet};
use net::adapter::net::behavior::fold::capability_aggregation::TagMatcher;
use net::adapter::net::behavior::ToolCapability;
use net::adapter::net::cortex::tool::{ToolDescriptor, ToolListChange};
use net::adapter::net::{EntityKeypair, MeshNode, MeshNodeConfig, SocketBufferConfig};
const TEST_BUFFER_SIZE: usize = 256 * 1024;
const PSK: [u8; 32] = [0x42u8; 32];
fn test_config() -> MeshNodeConfig {
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let mut cfg = MeshNodeConfig::new(addr, PSK)
.with_heartbeat_interval(Duration::from_millis(200))
.with_session_timeout(Duration::from_secs(5))
.with_handshake(3, Duration::from_secs(2))
.with_capability_gc_interval(Duration::from_millis(250))
.with_min_announce_interval(Duration::from_millis(0));
cfg.socket_buffers = SocketBufferConfig {
send_buffer_size: TEST_BUFFER_SIZE,
recv_buffer_size: TEST_BUFFER_SIZE,
};
cfg
}
async fn build_node() -> Arc<MeshNode> {
let cfg = test_config();
let keypair = EntityKeypair::generate();
Arc::new(MeshNode::new(keypair, cfg).await.expect("MeshNode::new"))
}
async fn handshake_pair(a: &Arc<MeshNode>, b: &Arc<MeshNode>) {
let a_id = a.node_id();
let b_id = b.node_id();
let b_pub = *b.public_key();
let b_addr = b.local_addr();
let b_clone = b.clone();
let accept = tokio::spawn(async move { b_clone.accept(a_id).await });
a.connect(b_addr, &b_pub, b_id)
.await
.expect("connect failed");
accept
.await
.expect("accept task panicked")
.expect("accept failed");
a.start();
b.start();
}
async fn wait_until<F: FnMut() -> bool>(mut cond: F, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
cond()
}
fn descriptor(tool_id: &str) -> ToolDescriptor {
let cap = ToolCapability::new(tool_id, format!("Name for {tool_id}"))
.with_version("1.0.0")
.with_input_schema(r#"{"type":"object","properties":{"query":{"type":"string"}}}"#);
ToolDescriptor::from_capability(&cap, &std::collections::BTreeMap::new())
}
#[tokio::test]
async fn announce_capabilities_merges_tool_registry_into_tags_and_caps() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
let mut desc = descriptor("web_search");
desc.description = Some("Search the web.".to_string());
desc.streaming = false;
desc.tags = vec!["web".to_string(), "research".to_string()];
host.tool_registry().insert(desc);
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce_capabilities");
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:web_search");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&tool_filter)
.contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer must see the host advertising `ai-tool:web_search` after announce; \
currently sees {:?}",
peer.find_nodes_by_filter(&tool_filter),
);
}
#[tokio::test]
async fn announce_capabilities_with_empty_registry_omits_tool_merge() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
assert!(host.tool_registry().is_empty());
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce_capabilities");
tokio::time::sleep(Duration::from_millis(400)).await;
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:web_search");
let hits = peer.find_nodes_by_filter(&tool_filter);
assert!(
!hits.contains(&host.node_id()),
"empty tool_registry must NOT emit any `ai-tool:*` tag; got {:?}",
hits,
);
}
#[tokio::test]
async fn drop_from_registry_clears_tag_on_next_announce() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
host.tool_registry().insert(descriptor("web_search"));
host.announce_capabilities(CapabilitySet::new())
.await
.expect("first announce");
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:web_search");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&tool_filter)
.contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer must see the host's initial announce",
);
let removed = host.tool_registry().remove("web_search");
assert!(removed.is_some(), "remove must return the prior entry");
assert!(host.tool_registry().is_empty());
}
#[tokio::test]
async fn list_tools_returns_descriptors_for_every_published_tool() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
let mut search = descriptor("web_search");
search.description = Some("Search the web.".to_string());
search.tags = vec!["web".to_string(), "research".to_string()];
host.tool_registry().insert(search);
host.tool_registry().insert(descriptor("calculator"));
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce");
let search_filter = CapabilityFilter::default().require_tag("ai-tool:web_search");
let calc_filter = CapabilityFilter::default().require_tag("ai-tool:calculator");
assert!(
wait_until(
|| {
peer.find_nodes_by_filter(&search_filter)
.contains(&host.node_id())
&& peer
.find_nodes_by_filter(&calc_filter)
.contains(&host.node_id())
},
Duration::from_secs(3),
)
.await,
"peer must see both tools announced",
);
let tools = peer.list_tools(None);
assert_eq!(tools.len(), 2, "expected 2 tools, got {tools:?}");
let by_id: std::collections::HashMap<&str, &ToolDescriptor> =
tools.iter().map(|t| (t.tool_id.as_str(), t)).collect();
let search = by_id.get("web_search").expect("web_search present");
assert_eq!(search.description.as_deref(), Some("Search the web."));
assert_eq!(search.tags, vec!["web", "research"]);
assert_eq!(search.node_count, 1);
let calc = by_id.get("calculator").expect("calculator present");
assert!(calc.description.is_none());
assert_eq!(calc.node_count, 1);
}
#[tokio::test]
async fn list_tools_hydrates_schemas_from_metadata() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
host.tool_registry().insert(descriptor("web_search"));
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce");
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:web_search");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&tool_filter)
.contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer must see ai-tool:web_search",
);
let tools = peer.list_tools(None);
let search = tools
.iter()
.find(|t| t.tool_id == "web_search")
.expect("web_search descriptor present");
let schema = search
.input_schema
.as_deref()
.expect("input_schema hydrated from metadata");
assert!(
schema.contains("\"query\""),
"schema must round-trip via metadata, got {schema:?}",
);
}
#[tokio::test]
async fn list_tools_filters_by_matcher_prefix() {
let host_eu = build_node().await;
let host_us = build_node().await;
let peer = build_node().await;
handshake_pair(&peer, &host_eu).await;
handshake_pair(&peer, &host_us).await;
host_eu.tool_registry().insert(descriptor("eu_tool"));
host_us.tool_registry().insert(descriptor("us_tool"));
let mut eu_caps = CapabilitySet::new();
eu_caps = eu_caps.add_tag("region.eu");
let mut us_caps = CapabilitySet::new();
us_caps = us_caps.add_tag("region.us");
host_eu
.announce_capabilities(eu_caps)
.await
.expect("eu announce");
host_us
.announce_capabilities(us_caps)
.await
.expect("us announce");
let eu_filter = CapabilityFilter::default().require_tag("ai-tool:eu_tool");
let us_filter = CapabilityFilter::default().require_tag("ai-tool:us_tool");
assert!(
wait_until(
|| {
peer.find_nodes_by_filter(&eu_filter)
.contains(&host_eu.node_id())
&& peer
.find_nodes_by_filter(&us_filter)
.contains(&host_us.node_id())
},
Duration::from_secs(3),
)
.await,
"peer must see both region tools",
);
let matcher = TagMatcher::Prefix {
value: "region.eu".to_string(),
};
let tools = peer.list_tools(Some(&matcher));
let ids: Vec<&str> = tools.iter().map(|t| t.tool_id.as_str()).collect();
assert_eq!(ids, vec!["eu_tool"], "expected only eu_tool, got {ids:?}");
}
#[tokio::test]
async fn watch_tools_emits_added_when_host_publishes_a_tool() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
let mut watch = peer.watch_tools(None, Some(Duration::from_millis(100)));
host.tool_registry().insert(descriptor("late_arrival"));
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce");
let event = tokio::time::timeout(Duration::from_secs(5), watch.next())
.await
.expect("watch produced an event in time")
.expect("stream did not close");
match event {
ToolListChange::Added(desc) => assert_eq!(desc.tool_id, "late_arrival"),
other => panic!("expected Added(late_arrival), got {other:?}"),
}
}
#[tokio::test]
async fn watch_tools_emits_node_count_changed_when_second_publisher_joins() {
let host_a = build_node().await;
let host_b = build_node().await;
let peer = build_node().await;
handshake_pair(&peer, &host_a).await;
handshake_pair(&peer, &host_b).await;
host_a.tool_registry().insert(descriptor("shared_tool"));
host_a
.announce_capabilities(CapabilitySet::new())
.await
.expect("a announce");
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:shared_tool");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&tool_filter)
.contains(&host_a.node_id()),
Duration::from_secs(3),
)
.await,
"peer must see shared_tool from A first",
);
let mut watch = peer.watch_tools(None, Some(Duration::from_millis(100)));
host_b.tool_registry().insert(descriptor("shared_tool"));
host_b
.announce_capabilities(CapabilitySet::new())
.await
.expect("b announce");
let event = loop {
let evt = tokio::time::timeout(Duration::from_secs(5), watch.next())
.await
.expect("watch produced an event in time")
.expect("stream did not close");
match evt {
ToolListChange::NodeCountChanged { .. } => break evt,
ToolListChange::Added(d) if d.tool_id == "shared_tool" => continue,
other => panic!("unexpected event: {other:?}"),
}
};
match event {
ToolListChange::NodeCountChanged {
descriptor: d,
prev_node_count,
} => {
assert_eq!(d.tool_id, "shared_tool");
assert_eq!(prev_node_count, 1);
assert_eq!(d.node_count, 2);
}
_ => unreachable!(),
}
}
#[tokio::test]
async fn watch_tools_polling_task_exits_when_handle_dropped() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
{
let _watch = peer.watch_tools(None, Some(Duration::from_millis(50)));
}
tokio::time::sleep(Duration::from_millis(150)).await;
host.tool_registry().insert(descriptor("post_drop"));
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce after drop");
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:post_drop");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&tool_filter)
.contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"post-drop announce must still propagate normally",
);
}
#[tokio::test]
async fn watch_tools_delivers_change_well_under_the_debounce_ceiling() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
let mut watch = peer.watch_tools(None, Some(Duration::from_secs(30)));
let started = std::time::Instant::now();
host.tool_registry().insert(descriptor("fast_path"));
host.announce_capabilities(CapabilitySet::new())
.await
.expect("announce");
let event = tokio::time::timeout(Duration::from_secs(2), watch.next())
.await
.expect("event must arrive far inside the 30s ceiling")
.expect("stream did not close");
match event {
ToolListChange::Added(desc) => assert_eq!(desc.tool_id, "fast_path"),
other => panic!("expected Added(fast_path), got {other:?}"),
}
assert!(
started.elapsed() < Duration::from_secs(5),
"change delivery took {:?}, expected ≪ 30s ceiling — \
watch is not event-driven",
started.elapsed(),
);
}
#[tokio::test]
async fn watch_tools_cancel_ends_the_stream_promptly() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair(&host, &peer).await;
let mut watch = peer.watch_tools(None, None);
watch.cancel();
let ended = tokio::time::timeout(Duration::from_secs(2), watch.next()).await;
assert!(
matches!(ended, Ok(None)),
"cancel must end the stream promptly; got {ended:?}",
);
}
#[tokio::test]
async fn list_tools_dedupes_and_aggregates_node_count() {
let host_a = build_node().await;
let host_b = build_node().await;
let peer = build_node().await;
handshake_pair(&peer, &host_a).await;
handshake_pair(&peer, &host_b).await;
host_a.tool_registry().insert(descriptor("shared_tool"));
host_b.tool_registry().insert(descriptor("shared_tool"));
host_a
.announce_capabilities(CapabilitySet::new())
.await
.expect("a announce");
host_b
.announce_capabilities(CapabilitySet::new())
.await
.expect("b announce");
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:shared_tool");
assert!(
wait_until(
|| {
let hits = peer.find_nodes_by_filter(&tool_filter);
hits.contains(&host_a.node_id()) && hits.contains(&host_b.node_id())
},
Duration::from_secs(3),
)
.await,
"peer must see both hosts under ai-tool:shared_tool",
);
let tools = peer.list_tools(None);
let shared = tools
.iter()
.find(|t| t.tool_id == "shared_tool")
.expect("shared_tool descriptor present");
assert_eq!(
shared.node_count, 2,
"shared tool must aggregate node_count across both hosts",
);
let count_rows = tools.iter().filter(|t| t.tool_id == "shared_tool").count();
assert_eq!(count_rows, 1, "dedupe must collapse duplicates");
}
#[tokio::test]
async fn local_caps_generation_tracks_registry_mutations() {
let host = build_node().await;
let base = host.local_caps_generation();
host.tool_registry().insert(descriptor("web_search"));
assert_eq!(
host.local_caps_generation(),
base + 1,
"serve_tool-path insert must bump the local-caps generation",
);
assert!(host.tool_registry().remove("web_search").is_some());
assert_eq!(
host.local_caps_generation(),
base + 2,
"ServeHandle-drop-path remove must bump the local-caps generation",
);
assert!(host.tool_registry().remove("web_search").is_none());
assert_eq!(
host.local_caps_generation(),
base + 2,
"remove of an absent tool is not a capability change",
);
}
async fn handshake_pair_host_arc(host: &Arc<MeshNode>, peer: &Arc<MeshNode>) {
let host_id = host.node_id();
let peer_id = peer.node_id();
let peer_pub = *peer.public_key();
let peer_addr = peer.local_addr();
let peer_clone = peer.clone();
let accept = tokio::spawn(async move { peer_clone.accept(host_id).await });
host.connect(peer_addr, &peer_pub, peer_id)
.await
.expect("connect failed");
accept
.await
.expect("accept task panicked")
.expect("accept failed");
host.start_arc();
peer.start();
}
#[tokio::test]
async fn registry_change_announces_without_explicit_call() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair_host_arc(&host, &peer).await;
host.tool_registry().insert(descriptor("auto_announced"));
let filter = CapabilityFilter::default().require_tag("ai-tool:auto_announced");
assert!(
wait_until(
|| peer.find_nodes_by_filter(&filter).contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer never saw the tool — the change-driven announcer did not fire",
);
}
#[tokio::test]
async fn registry_burst_coalesces_into_one_announce() {
let host = {
let cfg = test_config().with_announce_debounce(Duration::from_millis(300));
let keypair = EntityKeypair::generate();
Arc::new(MeshNode::new(keypair, cfg).await.expect("MeshNode::new"))
};
let peer = build_node().await;
handshake_pair_host_arc(&host, &peer).await;
let version_before = host.capability_announce_version();
host.tool_registry().insert(descriptor("burst_a"));
host.tool_registry().insert(descriptor("burst_b"));
host.tool_registry().insert(descriptor("burst_c"));
let all_visible = || {
["burst_a", "burst_b", "burst_c"].iter().all(|id| {
let filter = CapabilityFilter::default().require_tag(format!("ai-tool:{id}"));
peer.find_nodes_by_filter(&filter).contains(&host.node_id())
})
};
assert!(
wait_until(all_visible, Duration::from_secs(3)).await,
"peer never saw the full burst",
);
assert_eq!(
host.capability_announce_version(),
version_before + 1,
"a debounced burst must produce exactly one announce call",
);
}
async fn handshake_pair_host_bare_then_arc(host: &Arc<MeshNode>, peer: &Arc<MeshNode>) {
let host_id = host.node_id();
let peer_id = peer.node_id();
let peer_pub = *peer.public_key();
let peer_addr = peer.local_addr();
let peer_clone = peer.clone();
let accept = tokio::spawn(async move { peer_clone.accept(host_id).await });
host.connect(peer_addr, &peer_pub, peer_id)
.await
.expect("connect failed");
accept
.await
.expect("accept task panicked")
.expect("accept failed");
host.start();
host.start_arc();
peer.start();
}
#[tokio::test]
async fn registry_change_announces_after_bare_start_then_start_arc() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair_host_bare_then_arc(&host, &peer).await;
host.tool_registry().insert(descriptor("late_started"));
let filter = CapabilityFilter::default().require_tag("ai-tool:late_started");
assert!(
wait_until(
|| peer.find_nodes_by_filter(&filter).contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer never saw the tool — the change-driven announcer parked \
because start() ran before start_arc()",
);
}
#[tokio::test]
async fn change_driven_reannounce_preserves_explicit_baseline() {
let host = build_node().await;
let peer = build_node().await;
handshake_pair_host_arc(&host, &peer).await;
host.announce_capabilities(CapabilitySet::new().add_tag("baseline-keep"))
.await
.expect("announce baseline");
let base_filter = CapabilityFilter::default().require_tag("baseline-keep");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&base_filter)
.contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer never saw the explicit baseline",
);
host.tool_registry().insert(descriptor("added_tool"));
let tool_filter = CapabilityFilter::default().require_tag("ai-tool:added_tool");
assert!(
wait_until(
|| peer
.find_nodes_by_filter(&tool_filter)
.contains(&host.node_id()),
Duration::from_secs(3),
)
.await,
"peer never saw the change-driven tool announce",
);
assert!(
peer.find_nodes_by_filter(&base_filter)
.contains(&host.node_id()),
"the change-driven re-announce dropped the explicit baseline tag \
(it overwrote user_caps with a stale snapshot instead of re-reading it)",
);
}