use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};
use protocol_droid::application::registry::{RegistryStore, Subscriber, SubscriberSet};
use protocol_droid::application::session::{DynFrameSink, FrameSink, SinkError};
use protocol_droid::domain::registry::{AuthContext, Primitive, PrimitiveKind, Registered, Tool};
use protocol_droid::domain::{Feature, FeatureProfile};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const TOOLS_CHANGED: &str = r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#;
const RESOURCES_CHANGED: &str =
r#"{"jsonrpc":"2.0","method":"notifications/resources/list_changed"}"#;
#[derive(Default)]
struct Recorder {
frames: Mutex<Vec<String>>,
fail: bool,
}
impl Recorder {
fn failing() -> Arc<Self> {
Arc::new(Self {
frames: Mutex::new(Vec::new()),
fail: true,
})
}
fn count(&self) -> usize {
self.frames
.lock()
.map_or_else(|poisoned| poisoned.into_inner().len(), |held| held.len())
}
fn all(&self) -> Vec<String> {
self.frames.lock().map_or_else(
|poisoned| poisoned.into_inner().clone(),
|held| held.clone(),
)
}
}
impl FrameSink for Recorder {
#[expect(
clippy::unused_async_trait_impl,
reason = "FrameSink is a native async fn trait; the signature is not ours to change"
)]
async fn send(&self, frame: &str) -> Result<(), SinkError> {
if self.fail {
return Err(SinkError);
}
{
let mut held = self.frames.lock().unwrap_or_else(PoisonError::into_inner);
held.push(frame.to_owned());
}
Ok(())
}
}
fn tool_watch(profile: FeatureProfile, recorder: Arc<Recorder>) -> Subscriber {
Subscriber::new(
PrimitiveKind::Tool,
Feature::ToolsListChanged,
"notifications/tools/list_changed",
TOOLS_CHANGED,
profile,
recorder as Arc<dyn DynFrameSink>,
)
}
fn tool(name: &str) -> Result<Registered, Box<dyn std::error::Error>> {
Ok(Registered::from(Primitive::from(Tool::new(
name,
r#"{"type":"object"}"#,
)?)))
}
fn with_list_changed() -> FeatureProfile {
FeatureProfile::empty().with(Feature::ToolsListChanged)
}
fn without_list_changed() -> FeatureProfile {
FeatureProfile::empty().with(Feature::Tools)
}
#[tokio::test]
async fn hot_add_tool_leaves_in_flight_calls_on_the_old_snapshot() -> TestResult {
let store = RegistryStore::new();
store.register(tool("before")?);
let profile = with_list_changed();
let alice = AuthContext::unrestricted("alice");
let bob = AuthContext::unrestricted("bob");
let carol = AuthContext::unrestricted("carol");
let in_flight = [
store.list_visible(&alice, &profile, PrimitiveKind::Tool),
store.list_visible(&bob, &profile, PrimitiveKind::Tool),
store.list_visible(&carol, &profile, PrimitiveKind::Tool),
];
let resolved = in_flight
.iter()
.filter(|listing| !listing.visible().is_empty())
.count();
assert_eq!(
resolved, 3,
"each session should have resolved a tool before the publish"
);
let published = store.register(tool("after")?);
assert_eq!(published.changed(), [PrimitiveKind::Tool]);
for listing in &in_flight {
assert_eq!(
listing.visible().len(),
1,
"an in-flight listing must not grow when a tool is published"
);
assert!(
listing
.visible()
.iter()
.all(|held| held.id().as_str() == "before"),
"an in-flight call must complete against the tool it resolved"
);
}
let next = store.list_visible(&alice, &profile, PrimitiveKind::Tool);
let names: Vec<&str> = next
.visible()
.iter()
.map(|held| held.id().as_str())
.collect();
assert_eq!(
names,
vec!["before", "after"],
"the next list must include the new tool"
);
Ok(())
}
#[tokio::test]
async fn hot_remove_tool_leaves_in_flight_calls_on_the_old_snapshot() -> TestResult {
let store = RegistryStore::new();
store.register(tool("victim")?);
store.register(tool("bystander")?);
let profile = with_list_changed();
let alice = AuthContext::unrestricted("alice");
let bob = AuthContext::unrestricted("bob");
let carol = AuthContext::unrestricted("carol");
let in_flight = [
store.list_visible(&alice, &profile, PrimitiveKind::Tool),
store.list_visible(&bob, &profile, PrimitiveKind::Tool),
store.list_visible(&carol, &profile, PrimitiveKind::Tool),
];
for listing in &in_flight {
assert_eq!(listing.visible().len(), 2, "the victim is in the listing");
}
let published = store.unregister(PrimitiveKind::Tool, &"victim".into());
assert_eq!(published.changed(), [PrimitiveKind::Tool]);
assert!(
!published.is_noop(),
"removing a present tool is not a no-op"
);
for listing in &in_flight {
let names: Vec<&str> = listing
.visible()
.iter()
.map(|held| held.id().as_str())
.collect();
assert_eq!(
names,
vec!["victim", "bystander"],
"an in-flight listing must keep the tool it resolved"
);
}
let next = store.list_visible(&alice, &profile, PrimitiveKind::Tool);
let names: Vec<&str> = next
.visible()
.iter()
.map(|held| held.id().as_str())
.collect();
assert_eq!(
names,
vec!["bystander"],
"the next list must omit the removed tool but keep the bystander"
);
Ok(())
}
#[test]
fn get_visible_resolves_to_the_same_arc_across_publishes() -> TestResult {
let store = RegistryStore::new();
let _registered = store.register(tool("lookup")?);
let session = AuthContext::unrestricted("alice");
let profile = FeatureProfile::empty().with(Feature::Tools);
let Some(resolved) = store.get_visible(&session, &profile, PrimitiveKind::Tool, "lookup")
else {
return Err("the tool is in the store".into());
};
let resolved_id = resolved.id().clone();
let _ = store.register(tool("unrelated")?);
assert!(
store.snapshot().generation() > 1,
"the unrelated publish advanced the generation"
);
let Some(still_resolved) = store.get_visible(&session, &profile, PrimitiveKind::Tool, "lookup")
else {
return Err("a re-resolve after an unrelated publish must still find it".into());
};
assert_eq!(
still_resolved.id(),
&resolved_id,
"the resolved id is stable across unrelated publishes"
);
store.unregister(PrimitiveKind::Tool, &"lookup".into());
let re_resolved = store.get_visible(&session, &profile, PrimitiveKind::Tool, "lookup");
assert!(
re_resolved.is_none(),
"a removed primitive is a miss on the next call"
);
assert_eq!(
resolved.id(),
&resolved_id,
"the held Arc is unaffected by the unregister"
);
Ok(())
}
#[test]
fn visibility_for_one_primitive_is_independent_of_unrelated_primitives() -> TestResult {
let store = RegistryStore::new();
store.register(tool("a")?);
let session = AuthContext::unrestricted("alice");
let profile = FeatureProfile::empty().with(Feature::Tools);
let Some(first) = store.get_visible(&session, &profile, PrimitiveKind::Tool, "a") else {
return Err("a is in the store".into());
};
for name in ["x", "y", "z"] {
store.register(tool(name)?);
}
let Some(second) = store.get_visible(&session, &profile, PrimitiveKind::Tool, "a") else {
return Err("a is still in the store".into());
};
assert_eq!(first.id(), second.id());
assert!(
Arc::ptr_eq(&first, &second),
"a predicate that rebuilt the Arc would be a different pointer"
);
Ok(())
}
#[test]
fn a_reader_never_observes_a_rewound_generation() -> TestResult {
const ROUNDS: usize = 1_000;
const WRITERS: usize = 4;
let store = Arc::new(RegistryStore::new());
let rewound = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let mut writers = Vec::new();
for writer in 0..WRITERS {
let store = Arc::clone(&store);
writers.push(std::thread::spawn(move || {
for round in 0..ROUNDS {
let name = format!("w{writer}-r{round}");
let parsed = Tool::new(name.as_str(), r#"{"type":"object"}"#)?;
store.register(Primitive::from(parsed));
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}));
}
let reader = {
let store = Arc::clone(&store);
let rewound = Arc::clone(&rewound);
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
let mut last_seen: u64 = 0;
while !stop.load(Ordering::Relaxed) {
let observed = store.generation();
if observed < last_seen {
rewound.fetch_add(1, Ordering::Relaxed);
}
last_seen = observed;
}
})
};
for writer in writers {
writer
.join()
.map_err(|_| "a writer panicked")?
.map_err(|error| error.to_string())?;
}
stop.store(true, Ordering::Relaxed);
reader.join().map_err(|_| "the reader panicked")?;
assert_eq!(
rewound.load(Ordering::Relaxed),
0,
"the reader observed a generation smaller than one it had already seen"
);
Ok(())
}
#[tokio::test]
async fn one_publish_notifies_each_eligible_session_exactly_once() -> TestResult {
let store = RegistryStore::new();
let subscribers = SubscriberSet::new();
let profile = with_list_changed();
let alice = Arc::new(Recorder::default());
let bob = Arc::new(Recorder::default());
subscribers.add(tool_watch(profile, Arc::clone(&alice)));
subscribers.add(tool_watch(profile, Arc::clone(&bob)));
let published = store.register(tool("added")?);
let report = subscribers
.announce(published.snapshot(), published.changed())
.await;
assert_eq!(report.sent, 2, "both sessions negotiated listChanged");
assert_eq!(report.gated, 0);
assert_eq!(report.failed, 0);
assert_eq!(alice.count(), 1, "alice must be told exactly once");
assert_eq!(bob.count(), 1, "bob must be told exactly once");
assert_eq!(alice.all(), vec![TOOLS_CHANGED.to_owned()]);
Ok(())
}
#[tokio::test]
async fn each_publish_notifies_again() -> TestResult {
let store = RegistryStore::new();
let subscribers = SubscriberSet::new();
let recorder = Arc::new(Recorder::default());
subscribers.add(tool_watch(with_list_changed(), Arc::clone(&recorder)));
for name in ["a", "b", "c"] {
let published = store.register(tool(name)?);
subscribers
.announce(published.snapshot(), published.changed())
.await;
}
assert_eq!(recorder.count(), 3, "three publishes, three notifications");
Ok(())
}
#[tokio::test]
async fn list_changed_respects_the_negotiated_capability() -> TestResult {
let store = RegistryStore::new();
let subscribers = SubscriberSet::new();
let opted_in = Arc::new(Recorder::default());
let opted_out = Arc::new(Recorder::default());
subscribers.add(tool_watch(with_list_changed(), Arc::clone(&opted_in)));
subscribers.add(tool_watch(without_list_changed(), Arc::clone(&opted_out)));
let published = store.register(tool("added")?);
let report = subscribers
.announce(published.snapshot(), published.changed())
.await;
assert_eq!(report.sent, 1, "only the session that negotiated it");
assert_eq!(
report.gated, 1,
"the other was gated, not skipped by accident"
);
assert_eq!(opted_in.count(), 1);
assert_eq!(
opted_out.count(),
0,
"a session that never advertised tools.listChanged must not be sent one"
);
Ok(())
}
#[test]
fn a_gated_session_still_sees_the_tool_in_its_listing() -> TestResult {
let store = RegistryStore::new();
store.register(tool("added")?);
let session = AuthContext::unrestricted("quiet");
let listing = store.list_visible(&session, &without_list_changed(), PrimitiveKind::Tool);
assert_eq!(listing.visible().len(), 1);
assert_eq!(listing.total(), 1);
Ok(())
}
#[tokio::test]
async fn a_publish_that_changed_nothing_sends_nothing() -> TestResult {
let store = RegistryStore::new();
let subscribers = SubscriberSet::new();
let recorder = Arc::new(Recorder::default());
subscribers.add(tool_watch(with_list_changed(), Arc::clone(&recorder)));
let published = store.unregister(PrimitiveKind::Tool, &"never-registered".into());
assert!(published.is_noop(), "removing an absent tool is a no-op");
let report = subscribers
.announce(published.snapshot(), published.changed())
.await;
assert!(report.is_silent());
assert_eq!(recorder.count(), 0);
Ok(())
}
#[tokio::test]
async fn a_tool_publish_does_not_reach_a_resource_subscriber() -> TestResult {
let store = RegistryStore::new();
let subscribers = SubscriberSet::new();
let watching_tools = Arc::new(Recorder::default());
let watching_resources = Arc::new(Recorder::default());
subscribers.add(tool_watch(with_list_changed(), Arc::clone(&watching_tools)));
subscribers.add(Subscriber::new(
PrimitiveKind::Resource,
Feature::ResourcesListChanged,
"notifications/resources/list_changed",
RESOURCES_CHANGED,
with_list_changed(),
Arc::clone(&watching_resources) as Arc<dyn DynFrameSink>,
));
let published = store.register(tool("added")?);
subscribers
.announce(published.snapshot(), published.changed())
.await;
assert_eq!(watching_tools.count(), 1);
assert_eq!(watching_resources.count(), 0);
Ok(())
}
#[tokio::test]
async fn a_failing_sink_does_not_stop_the_fan_out() -> TestResult {
let store = RegistryStore::new();
let subscribers = SubscriberSet::new();
let dead = Recorder::failing();
let alive = Arc::new(Recorder::default());
subscribers.add(tool_watch(with_list_changed(), Arc::clone(&dead)));
subscribers.add(tool_watch(with_list_changed(), Arc::clone(&alive)));
let published = store.register(tool("added")?);
let report = subscribers
.announce(published.snapshot(), published.changed())
.await;
assert_eq!(report.sent, 1);
assert_eq!(report.failed, 1);
assert_eq!(alive.count(), 1, "the live session is still told");
Ok(())
}
#[test]
fn the_generation_advances_with_every_publish() -> TestResult {
let store = RegistryStore::new();
assert_eq!(store.generation(), 0);
let first = store.register(tool("a")?);
assert_eq!(first.generation(), 1);
assert_eq!(store.generation(), 1);
let replaced = store.register(tool("a")?);
assert_eq!(replaced.generation(), 2, "a replace is one generation");
assert_eq!(store.snapshot().len(PrimitiveKind::Tool), 1);
let noop = store.unregister(PrimitiveKind::Tool, &"absent".into());
assert!(noop.is_noop());
assert_eq!(noop.generation(), 3);
Ok(())
}
#[test]
fn a_batch_is_one_generation() -> TestResult {
let store = RegistryStore::new();
let published = store.apply([tool("a")?, tool("b")?, tool("c")?]);
assert_eq!(published.generation(), 1, "three tools, one generation");
assert_eq!(published.changed(), [PrimitiveKind::Tool]);
assert_eq!(store.snapshot().len(PrimitiveKind::Tool), 3);
Ok(())
}
#[test]
fn a_tool_needing_an_absent_feature_is_hidden() -> TestResult {
let store = RegistryStore::new();
let structured: Primitive = Tool::new("structured", r#"{"type":"object"}"#)?
.with_output_schema(r#"{"type":"object"}"#)?
.into();
store.register(structured);
store.register(tool("plain")?);
let session = AuthContext::unrestricted("alice");
let without = store.list_visible(
&session,
&FeatureProfile::empty().with(Feature::Tools),
PrimitiveKind::Tool,
);
assert_eq!(without.visible().len(), 1, "the structured tool is hidden");
assert_eq!(without.total(), 2, "but the server still holds both");
assert_eq!(without.hidden(), 1);
let with = store.list_visible(
&session,
&FeatureProfile::empty()
.with(Feature::Tools)
.with(Feature::ToolStructuredOutput),
PrimitiveKind::Tool,
);
assert_eq!(
with.visible().len(),
2,
"with the feature, both are visible"
);
Ok(())
}
#[test]
fn an_unauthorised_principal_gets_a_miss_not_a_refusal() -> TestResult {
let store = RegistryStore::new();
let guarded = Registered::from(Primitive::from(Tool::new(
"guarded",
r#"{"type":"object"}"#,
)?))
.requiring_scope(protocol_droid::domain::registry::Scope::Admin);
store.register(guarded);
let plain = AuthContext::new(
"mallory",
protocol_droid::domain::registry::ScopeSet::empty(),
);
let admin = AuthContext::unrestricted("root");
let miss = store.get_visible(&plain, &with_list_changed(), PrimitiveKind::Tool, "guarded");
assert!(miss.is_none(), "a hidden tool is a miss, not an error");
let absent = store.get_visible(
&plain,
&with_list_changed(),
PrimitiveKind::Tool,
"no-such-tool",
);
assert_eq!(
miss.is_none(),
absent.is_none(),
"hidden and absent must be indistinguishable, or the tool list leaks"
);
assert!(
store
.get_visible(&admin, &with_list_changed(), PrimitiveKind::Tool, "guarded")
.is_some()
);
Ok(())
}
#[test]
fn a_reader_never_sees_a_torn_snapshot() -> TestResult {
const WRITES: usize = 4;
const READERS: usize = 4;
const ROUNDS: usize = 250;
let store = Arc::new(RegistryStore::new());
let torn = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let mut writers = Vec::new();
for writer in 0..WRITES {
let store = Arc::clone(&store);
writers.push(std::thread::spawn(move || {
for round in 0..ROUNDS {
let name = format!("tool-{writer}-{round}");
let parsed = Tool::new(name.as_str(), r#"{"type":"object"}"#)?;
store.register(Primitive::from(parsed));
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}));
}
let mut readers = Vec::new();
for _ in 0..READERS {
let store = Arc::clone(&store);
let torn = Arc::clone(&torn);
let stop = Arc::clone(&stop);
readers.push(std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let snapshot = store.snapshot();
for primitive in snapshot.list(PrimitiveKind::Tool) {
let id = primitive.id().clone();
if snapshot.get(PrimitiveKind::Tool, &id).is_none() {
torn.fetch_add(1, Ordering::Relaxed);
}
}
}
}));
}
for writer in writers {
writer
.join()
.map_err(|_| "a writer panicked")?
.map_err(|error| error.to_string())?;
}
stop.store(true, Ordering::Relaxed);
for reader in readers {
reader.join().map_err(|_| "a reader panicked")?;
}
assert_eq!(
torn.load(Ordering::Relaxed),
0,
"a reader observed a snapshot whose listing and index disagreed"
);
assert_eq!(store.snapshot().len(PrimitiveKind::Tool), WRITES * ROUNDS);
Ok(())
}