use crate::inbox::{AdmissionOutcome, DropReason, InboxSender};
#[cfg(target_arch = "wasm32")]
use crate::tokio;
use crate::types::InboxItem;
use meerkat_core::PlainEventSource;
use meerkat_core::event_injector::{EventInjector, EventInjectorError};
use meerkat_core::types::{ContentInput, HandlingMode, RenderMetadata};
pub type SubscriberRegistry = std::sync::Arc<
parking_lot::Mutex<
std::collections::HashMap<uuid::Uuid, tokio::sync::mpsc::Sender<meerkat_core::AgentEvent>>,
>,
>;
pub fn new_subscriber_registry() -> SubscriberRegistry {
std::sync::Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new()))
}
pub struct CommsEventInjector {
sender: InboxSender,
subscriber_registry: SubscriberRegistry,
}
impl CommsEventInjector {
pub fn new(sender: InboxSender, registry: SubscriberRegistry) -> Self {
Self {
sender,
subscriber_registry: registry,
}
}
pub fn inject_with_interaction_id(
&self,
interaction_id: uuid::Uuid,
content: ContentInput,
source: PlainEventSource,
handling_mode: HandlingMode,
render_metadata: Option<RenderMetadata>,
) -> Result<(), EventInjectorError> {
let body = content.text_content();
let blocks = match content {
ContentInput::Text(_) => None,
ContentInput::Blocks(blocks) => Some(blocks),
};
match self.sender.send_classified(InboxItem::PlainEvent {
body,
source,
handling_mode,
interaction_id: Some(interaction_id),
blocks,
render_metadata,
}) {
AdmissionOutcome::Admitted => Ok(()),
AdmissionOutcome::Dropped { reason } => Err(drop_reason_to_injector_error(reason)),
}
}
}
impl EventInjector for CommsEventInjector {
fn inject(
&self,
content: ContentInput,
source: PlainEventSource,
handling_mode: HandlingMode,
render_metadata: Option<RenderMetadata>,
) -> Result<(), EventInjectorError> {
let body = content.text_content();
let blocks = match content {
ContentInput::Text(_) => None,
ContentInput::Blocks(blocks) => Some(blocks),
};
match self.sender.send_classified(InboxItem::PlainEvent {
body,
source,
handling_mode,
interaction_id: None,
blocks,
render_metadata,
}) {
AdmissionOutcome::Admitted => Ok(()),
AdmissionOutcome::Dropped { reason } => Err(drop_reason_to_injector_error(reason)),
}
}
}
fn drop_reason_to_injector_error(reason: DropReason) -> EventInjectorError {
match reason {
DropReason::InboxFull => EventInjectorError::Full,
DropReason::SessionClosed => EventInjectorError::Closed,
DropReason::UntrustedSender | DropReason::ClassificationRejected => {
EventInjectorError::Closed
}
}
}
impl meerkat_core::event_injector::SubscribableInjector for CommsEventInjector {
fn inject_with_subscription(
&self,
content: ContentInput,
source: PlainEventSource,
handling_mode: HandlingMode,
render_metadata: Option<RenderMetadata>,
) -> Result<meerkat_core::event_injector::InteractionSubscription, EventInjectorError> {
let id = uuid::Uuid::new_v4();
let (tx, rx) = tokio::sync::mpsc::channel(4096);
let body = content.text_content();
let blocks = match content {
ContentInput::Text(_) => None,
ContentInput::Blocks(blocks) => Some(blocks),
};
self.subscriber_registry.lock().insert(id, tx);
if let AdmissionOutcome::Dropped { reason } =
self.sender.send_classified(InboxItem::PlainEvent {
body,
source,
handling_mode,
interaction_id: Some(id),
blocks,
render_metadata,
})
{
self.subscriber_registry.lock().remove(&id);
return Err(drop_reason_to_injector_error(reason));
}
Ok(meerkat_core::event_injector::InteractionSubscription {
id: meerkat_core::InteractionId(id),
events: rx,
})
}
fn inject_with_interaction_id(
&self,
interaction_id: meerkat_core::InteractionId,
content: ContentInput,
source: PlainEventSource,
handling_mode: HandlingMode,
render_metadata: Option<RenderMetadata>,
) -> Result<(), EventInjectorError> {
CommsEventInjector::inject_with_interaction_id(
self,
interaction_id.0,
content,
source,
handling_mode,
render_metadata,
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::classify::test_support;
use crate::inbox::Inbox;
use crate::trust::TrustStore;
fn classified_inbox() -> (Inbox, InboxSender) {
Inbox::new_classified(test_support::classification_context(
TrustStore::new(),
false,
))
}
#[test]
fn test_comms_event_injector_sends_to_inbox() {
let (mut inbox, sender) = classified_inbox();
let injector = CommsEventInjector::new(sender, new_subscriber_registry());
injector
.inject(
"hello".to_string().into(),
PlainEventSource::Tcp,
HandlingMode::Queue,
None,
)
.unwrap();
let items = inbox.try_drain_classified();
assert_eq!(items.len(), 1);
match &items[0].item {
InboxItem::PlainEvent { body, source, .. } => {
assert_eq!(body, "hello");
assert_eq!(*source, PlainEventSource::Tcp);
}
other => panic!("Expected PlainEvent, got {other:?}"),
}
}
#[test]
fn test_comms_event_injector_reports_full() {
let (_inbox, sender) = Inbox::new_classified_with_capacity_for_test(
test_support::classification_context(TrustStore::new(), false),
1,
);
let injector = CommsEventInjector::new(sender, new_subscriber_registry());
injector
.inject(
"first".to_string().into(),
PlainEventSource::Tcp,
HandlingMode::Queue,
None,
)
.unwrap();
let result = injector.inject(
"second".to_string().into(),
PlainEventSource::Tcp,
HandlingMode::Queue,
None,
);
assert!(
matches!(result, Err(EventInjectorError::Full)),
"Expected Full error"
);
}
#[test]
fn test_comms_event_injector_as_dyn() {
use std::sync::Arc;
let (mut inbox, sender) = classified_inbox();
let injector: Arc<dyn EventInjector> =
Arc::new(CommsEventInjector::new(sender, new_subscriber_registry()));
injector
.inject(
"via dyn".to_string().into(),
PlainEventSource::Webhook,
HandlingMode::Queue,
None,
)
.unwrap();
let items = inbox.try_drain_classified();
assert_eq!(items.len(), 1);
}
#[test]
fn test_inject_with_subscription_stores_subscriber_and_sends_item() {
use meerkat_core::event_injector::SubscribableInjector;
let registry = new_subscriber_registry();
let (mut inbox, sender) = classified_inbox();
let injector = CommsEventInjector::new(sender, registry.clone());
let sub = injector
.inject_with_subscription(
"tracked".to_string().into(),
PlainEventSource::Rpc,
HandlingMode::Queue,
None,
)
.unwrap();
let items = inbox.try_drain_classified();
assert_eq!(items.len(), 1);
match &items[0].item {
InboxItem::PlainEvent {
body,
interaction_id,
..
} => {
assert_eq!(body, "tracked");
assert_eq!(*interaction_id, Some(sub.id.0));
}
other => panic!("Expected PlainEvent, got {other:?}"),
}
assert!(registry.lock().contains_key(&sub.id.0));
}
#[test]
fn test_inject_with_subscription_cleans_up_on_full() {
use meerkat_core::event_injector::SubscribableInjector;
let registry = new_subscriber_registry();
let (_inbox, sender) = Inbox::new_classified_with_capacity_for_test(
test_support::classification_context(TrustStore::new(), false),
1,
);
let injector = CommsEventInjector::new(sender, registry.clone());
injector
.inject(
"first".to_string().into(),
PlainEventSource::Tcp,
HandlingMode::Queue,
None,
)
.unwrap();
let result = injector.inject_with_subscription(
"second".to_string().into(),
PlainEventSource::Tcp,
HandlingMode::Queue,
None,
);
assert!(matches!(result, Err(EventInjectorError::Full)));
assert!(registry.lock().is_empty());
}
#[test]
fn test_inject_with_subscription_cleans_up_on_closed_inbox() {
use meerkat_core::event_injector::SubscribableInjector;
let registry = new_subscriber_registry();
let (inbox, sender) = Inbox::new();
drop(inbox);
let injector = CommsEventInjector::new(sender, registry.clone());
let result = injector.inject_with_subscription(
"closed".to_string().into(),
PlainEventSource::Tcp,
HandlingMode::Queue,
None,
);
assert!(matches!(result, Err(EventInjectorError::Closed)));
assert!(registry.lock().is_empty(), "registry should be cleaned up");
}
}