use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
use tokio::sync::Semaphore;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use uuid::Uuid;
use super::agent::{AgentRuntime, RunOutput};
use super::error::RuntimeError;
use super::event::AgentEvent;
use super::run::{RunId, RunRequest};
use super::stream::RuntimeEventEnvelope;
use behest_provider::{ChatStreamEvent, ModelName, ProviderId, ToolChoice};
#[derive(Debug, Clone)]
pub struct EmitRequest {
pub provider: ProviderId,
pub model: ModelName,
pub input: String,
pub session_id: Option<Uuid>,
pub client_request_id: Option<String>,
pub metadata: Value,
}
impl EmitRequest {
#[must_use]
pub fn new(provider: ProviderId, model: ModelName, input: impl Into<String>) -> Self {
Self {
provider,
model,
input: input.into(),
session_id: None,
client_request_id: None,
metadata: Value::Null,
}
}
#[must_use]
pub fn with_session_id(mut self, session_id: Uuid) -> Self {
self.session_id = Some(session_id);
self
}
#[must_use]
pub fn with_client_request_id(mut self, id: impl Into<String>) -> Self {
self.client_request_id = Some(id.into());
self
}
#[must_use]
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = metadata;
self
}
#[must_use]
pub fn into_run_request(self) -> RunRequest {
RunRequest {
session_id: self.session_id,
run_id: None,
provider: self.provider,
model: self.model,
input: self.input,
metadata: self.metadata,
tool_choice: ToolChoice::Auto,
client_request_id: self.client_request_id,
}
}
}
#[derive(Debug, Error)]
pub enum InvocationError {
#[error(transparent)]
Runtime(#[from] RuntimeError),
#[error("invocation task failed: {message}")]
TaskFailed {
message: String,
},
#[error("invalid invocation request: {message}")]
InvalidRequest {
message: String,
},
}
#[derive(Debug, Error)]
pub enum SessionDataError {
#[error("session data key not found: {session_id}/{key}")]
NotFound {
session_id: Uuid,
key: String,
},
#[error("session data storage error: {message}")]
Storage {
message: String,
},
}
#[async_trait]
pub trait SessionDataStore: Send + Sync {
async fn set(
&self,
session_id: Uuid,
key: String,
value: Value,
) -> Result<(), SessionDataError>;
async fn get(&self, session_id: Uuid, key: &str) -> Result<Option<Value>, SessionDataError>;
async fn delete(&self, session_id: Uuid, key: &str) -> Result<(), SessionDataError>;
}
#[derive(Clone)]
pub struct MemorySessionDataStore {
data: Arc<Mutex<HashMap<(Uuid, String), Value>>>,
}
impl Default for MemorySessionDataStore {
fn default() -> Self {
Self::new()
}
}
impl MemorySessionDataStore {
#[must_use]
pub fn new() -> Self {
Self {
data: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl fmt::Debug for MemorySessionDataStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MemorySessionDataStore")
.finish_non_exhaustive()
}
}
#[async_trait]
impl SessionDataStore for MemorySessionDataStore {
async fn set(
&self,
session_id: Uuid,
key: String,
value: Value,
) -> Result<(), SessionDataError> {
let mut map = self
.data
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.insert((session_id, key), value);
Ok(())
}
async fn get(&self, session_id: Uuid, key: &str) -> Result<Option<Value>, SessionDataError> {
let map = self
.data
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(map.get(&(session_id, key.to_string())).cloned())
}
async fn delete(&self, session_id: Uuid, key: &str) -> Result<(), SessionDataError> {
let mut map = self
.data
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.remove(&(session_id, key.to_string()));
Ok(())
}
}
pub struct FileSessionDataStore {
base_dir: PathBuf,
locks: Arc<Mutex<HashMap<Uuid, Arc<Mutex<()>>>>>,
}
impl FileSessionDataStore {
#[must_use]
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
locks: Arc::new(Mutex::new(HashMap::new())),
}
}
fn session_path(&self, session_id: Uuid) -> PathBuf {
self.base_dir.join(format!("{session_id}.json"))
}
fn session_lock(&self, session_id: Uuid) -> Arc<Mutex<()>> {
let mut map = self
.locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.entry(session_id)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
}
impl fmt::Debug for FileSessionDataStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FileSessionDataStore")
.field("base_dir", &self.base_dir)
.finish_non_exhaustive()
}
}
#[async_trait]
impl SessionDataStore for FileSessionDataStore {
async fn set(
&self,
session_id: Uuid,
key: String,
value: Value,
) -> Result<(), SessionDataError> {
let path = self.session_path(session_id);
let lock = self.session_lock(session_id);
let base = self.base_dir.clone();
tokio::task::spawn_blocking(move || {
let _guard = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::fs::create_dir_all(&base).map_err(|e| SessionDataError::Storage {
message: format!("failed to create base dir: {e}"),
})?;
let mut map: HashMap<String, Value> = if path.exists() {
let data =
std::fs::read_to_string(&path).map_err(|e| SessionDataError::Storage {
message: format!("failed to read session file: {e}"),
})?;
serde_json::from_str(&data).map_err(|e| SessionDataError::Storage {
message: format!("failed to parse session file: {e}"),
})?
} else {
HashMap::new()
};
map.insert(key, value);
let json =
serde_json::to_string_pretty(&map).map_err(|e| SessionDataError::Storage {
message: format!("failed to serialize session data: {e}"),
})?;
std::fs::write(&path, json).map_err(|e| SessionDataError::Storage {
message: format!("failed to write session file: {e}"),
})?;
Ok(())
})
.await
.map_err(|e| SessionDataError::Storage {
message: format!("spawn_blocking error: {e}"),
})?
}
async fn get(&self, session_id: Uuid, key: &str) -> Result<Option<Value>, SessionDataError> {
let path = self.session_path(session_id);
let lock = self.session_lock(session_id);
let key = key.to_string();
tokio::task::spawn_blocking(move || {
let _guard = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&path).map_err(|e| SessionDataError::Storage {
message: format!("failed to read session file: {e}"),
})?;
let map: HashMap<String, Value> =
serde_json::from_str(&data).map_err(|e| SessionDataError::Storage {
message: format!("failed to parse session file: {e}"),
})?;
Ok(map.get(&key).cloned())
})
.await
.map_err(|e| SessionDataError::Storage {
message: format!("spawn_blocking error: {e}"),
})?
}
async fn delete(&self, session_id: Uuid, key: &str) -> Result<(), SessionDataError> {
let path = self.session_path(session_id);
let lock = self.session_lock(session_id);
let key = key.to_string();
tokio::task::spawn_blocking(move || {
let _guard = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !path.exists() {
return Ok(());
}
let data = std::fs::read_to_string(&path).map_err(|e| SessionDataError::Storage {
message: format!("failed to read session file: {e}"),
})?;
let mut map: HashMap<String, Value> =
serde_json::from_str(&data).map_err(|e| SessionDataError::Storage {
message: format!("failed to parse session file: {e}"),
})?;
map.remove(&key);
let json =
serde_json::to_string_pretty(&map).map_err(|e| SessionDataError::Storage {
message: format!("failed to serialize session data: {e}"),
})?;
std::fs::write(&path, json).map_err(|e| SessionDataError::Storage {
message: format!("failed to write session file: {e}"),
})?;
Ok(())
})
.await
.map_err(|e| SessionDataError::Storage {
message: format!("spawn_blocking error: {e}"),
})?
}
}
#[derive(Debug, Clone)]
pub enum InvocationEvent {
Agent(AgentEvent),
Chat(ChatStreamEvent),
}
impl InvocationEvent {
#[must_use]
pub fn run_id(&self) -> Option<RunId> {
match self {
InvocationEvent::Agent(e) => Some(e.run_id()),
InvocationEvent::Chat(_) => None,
}
}
#[must_use]
pub fn as_agent(&self) -> Option<&AgentEvent> {
match self {
InvocationEvent::Agent(e) => Some(e),
InvocationEvent::Chat(_) => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventKind {
Any,
RunStarted,
ContextBuilt,
ModelStarted,
TextDelta,
ToolCallStarted,
ToolCallDelta,
ToolCallCompleted,
ToolExecutionStarted,
ToolExecutionFinished,
AssistantMessageCommitted,
ToolMessageCommitted,
UsageRecorded,
CacheMetrics,
RunCompleted,
RunFailed,
RunCancelled,
DoomLoopDetected,
CompactionCircuitOpened,
ChatStarted,
ChatTextDelta,
ChatToolCallStarted,
ChatToolCallArgumentsDelta,
ChatToolCallCompleted,
ChatFinished,
}
impl EventKind {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn matches_agent(self, event: &AgentEvent) -> bool {
match self {
Self::Any => true,
Self::RunStarted => matches!(event, AgentEvent::RunStarted(_)),
Self::ContextBuilt => matches!(event, AgentEvent::ContextBuilt(_)),
Self::ModelStarted => matches!(event, AgentEvent::ModelStarted(_)),
Self::TextDelta => matches!(event, AgentEvent::TextDelta(_)),
Self::ToolCallStarted => matches!(event, AgentEvent::ToolCallStarted(_)),
Self::ToolCallDelta => matches!(event, AgentEvent::ToolCallDelta(_)),
Self::ToolCallCompleted => matches!(event, AgentEvent::ToolCallCompleted(_)),
Self::ToolExecutionStarted => matches!(event, AgentEvent::ToolExecutionStarted(_)),
Self::ToolExecutionFinished => matches!(event, AgentEvent::ToolExecutionFinished(_)),
Self::AssistantMessageCommitted => {
matches!(event, AgentEvent::AssistantMessageCommitted(_))
}
Self::ToolMessageCommitted => matches!(event, AgentEvent::ToolMessageCommitted(_)),
Self::UsageRecorded => matches!(event, AgentEvent::UsageRecorded(_)),
Self::CacheMetrics => matches!(event, AgentEvent::CacheMetrics(_)),
Self::RunCompleted => matches!(event, AgentEvent::RunCompleted(_)),
Self::RunFailed => matches!(event, AgentEvent::RunFailed(_)),
Self::RunCancelled => matches!(event, AgentEvent::RunCancelled(_)),
Self::DoomLoopDetected => matches!(event, AgentEvent::DoomLoopDetected(_)),
Self::CompactionCircuitOpened => {
matches!(event, AgentEvent::CompactionCircuitOpened(_))
}
Self::ChatStarted
| Self::ChatTextDelta
| Self::ChatToolCallStarted
| Self::ChatToolCallArgumentsDelta
| Self::ChatToolCallCompleted
| Self::ChatFinished => false,
}
}
#[must_use]
pub fn matches(self, event: &InvocationEvent) -> bool {
match event {
InvocationEvent::Agent(e) => self.matches_agent(e),
InvocationEvent::Chat(e) => self.matches_chat(e),
}
}
#[must_use]
fn matches_chat(self, event: &ChatStreamEvent) -> bool {
match self {
Self::Any => true,
Self::ChatStarted => matches!(event, ChatStreamEvent::Started { .. }),
Self::ChatTextDelta => matches!(event, ChatStreamEvent::TextDelta { .. }),
Self::ChatToolCallStarted => matches!(event, ChatStreamEvent::ToolCallStarted { .. }),
Self::ChatToolCallArgumentsDelta => {
matches!(event, ChatStreamEvent::ToolCallArgumentsDelta { .. })
}
Self::ChatToolCallCompleted => {
matches!(event, ChatStreamEvent::ToolCallCompleted { .. })
}
Self::ChatFinished => matches!(event, ChatStreamEvent::Finished { .. }),
_ => false,
}
}
}
pub struct InvocationSession {
pub session_id: Option<Uuid>,
pub run_id: Option<RunId>,
store: Arc<dyn SessionDataStore>,
}
impl InvocationSession {
pub async fn set_data(
&self,
key: impl Into<String>,
value: Value,
) -> Result<(), SessionDataError> {
let session_id = self.session_id.ok_or(SessionDataError::Storage {
message: "session_id not available".into(),
})?;
self.store.set(session_id, key.into(), value).await
}
pub async fn get_data(&self, key: &str) -> Result<Option<Value>, SessionDataError> {
let session_id = self.session_id.ok_or(SessionDataError::Storage {
message: "session_id not available".into(),
})?;
self.store.get(session_id, key).await
}
pub async fn delete_data(&self, key: &str) -> Result<(), SessionDataError> {
let session_id = self.session_id.ok_or(SessionDataError::Storage {
message: "session_id not available".into(),
})?;
self.store.delete(session_id, key).await
}
}
impl fmt::Debug for InvocationSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InvocationSession")
.field("session_id", &self.session_id)
.field("run_id", &self.run_id)
.finish_non_exhaustive()
}
}
#[derive(Debug)]
struct ControlInner {
cancelled: AtomicBool,
timeout: Mutex<Option<Duration>>,
concurrency_limit: Mutex<Option<usize>>,
extensions: Mutex<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
}
#[derive(Debug, Clone)]
pub struct Control {
inner: Arc<ControlInner>,
}
impl Default for Control {
fn default() -> Self {
Self::new()
}
}
impl Control {
#[must_use]
pub fn new() -> Self {
Self {
inner: Arc::new(ControlInner {
cancelled: AtomicBool::new(false),
timeout: Mutex::new(None),
concurrency_limit: Mutex::new(None),
extensions: Mutex::new(HashMap::new()),
}),
}
}
pub fn cancel(&self) {
self.inner.cancelled.store(true, Ordering::Release);
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.inner.cancelled.load(Ordering::Acquire)
}
pub fn set_timeout(&self, timeout: Duration) {
*lock_or_recover(&self.inner.timeout) = Some(timeout);
}
#[must_use]
pub fn timeout(&self) -> Option<Duration> {
*lock_or_recover(&self.inner.timeout)
}
pub fn set_concurrency_limit(&self, limit: usize) {
*lock_or_recover(&self.inner.concurrency_limit) = Some(limit.max(1));
}
#[must_use]
pub fn concurrency_limit(&self) -> Option<usize> {
*lock_or_recover(&self.inner.concurrency_limit)
}
pub fn set_data<T: Send + Sync + 'static>(&self, val: T) {
let mut ext = lock_or_recover(&self.inner.extensions);
ext.insert(TypeId::of::<T>(), Arc::new(val));
}
#[must_use]
pub fn data<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
let ext = lock_or_recover(&self.inner.extensions);
ext.get(&TypeId::of::<T>())
.and_then(|arc| Arc::clone(arc).downcast::<T>().ok())
}
}
fn lock_or_recover<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
lock.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[derive(Debug)]
pub struct InvocationHandle {
task: JoinHandle<()>,
}
impl InvocationHandle {
pub fn abort(&self) {
self.task.abort();
}
#[must_use]
pub fn is_finished(&self) -> bool {
self.task.is_finished()
}
}
impl Drop for InvocationHandle {
fn drop(&mut self) {
self.task.abort();
}
}
#[derive(Clone)]
pub struct RuntimeInvocation {
runtime: Arc<AgentRuntime>,
session_map: Arc<Mutex<HashMap<RunId, Uuid>>>,
initial_data: Arc<Mutex<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>>,
session_data_store: Arc<dyn SessionDataStore>,
}
impl RuntimeInvocation {
#[must_use]
pub fn new(runtime: Arc<AgentRuntime>) -> Self {
Self {
runtime,
session_map: Arc::new(Mutex::new(HashMap::new())),
initial_data: Arc::new(Mutex::new(HashMap::new())),
session_data_store: Arc::new(MemorySessionDataStore::new()),
}
}
#[must_use]
pub fn with_session_store(
runtime: Arc<AgentRuntime>,
store: Arc<dyn SessionDataStore>,
) -> Self {
Self {
runtime,
session_map: Arc::new(Mutex::new(HashMap::new())),
initial_data: Arc::new(Mutex::new(HashMap::new())),
session_data_store: store,
}
}
#[must_use]
pub fn runtime(&self) -> &AgentRuntime {
&self.runtime
}
pub fn set_data<T: Send + Sync + 'static>(&self, val: T) {
let mut map = lock_or_recover(&self.initial_data);
map.insert(TypeId::of::<T>(), Arc::new(val));
}
fn make_control(&self) -> Control {
let control = Control::new();
let extensions = lock_or_recover(&self.initial_data);
let mut target = lock_or_recover(&control.inner.extensions);
for (type_id, arc) in extensions.iter() {
target.insert(*type_id, Arc::clone(arc));
}
drop(target);
drop(extensions);
control
}
pub async fn emit<F, Fut>(&self, f: F) -> Result<RunOutput, InvocationError>
where
F: FnOnce(InvocationSession, Control) -> Fut + Send,
Fut: Future<Output = EmitRequest> + Send,
{
let control = self.make_control();
let session = InvocationSession {
session_id: None,
run_id: None,
store: Arc::clone(&self.session_data_store),
};
if control.is_cancelled() {
return Err(InvocationError::TaskFailed {
message: "cancelled".into(),
});
}
let request = f(session, control.clone()).await;
if control.is_cancelled() {
return Err(InvocationError::TaskFailed {
message: "cancelled".into(),
});
}
let run_request = request.into_run_request();
let output = self.runtime.run(run_request).await?;
Ok(output)
}
#[allow(clippy::unused_async)]
pub async fn on<F, Fut>(
&self,
kind: EventKind,
f: F,
) -> Result<InvocationHandle, InvocationError>
where
F: Fn(RuntimeEventEnvelope, InvocationSession, Control) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let receiver = self.runtime.subscribe();
let control = self.make_control();
let session_map = Arc::clone(&self.session_map);
let store = Arc::clone(&self.session_data_store);
Ok(spawn_listener(
receiver,
kind,
control,
session_map,
store,
f,
))
}
}
fn spawn_listener<F, Fut>(
mut receiver: broadcast::Receiver<AgentEvent>,
kind: EventKind,
control: Control,
session_map: Arc<Mutex<HashMap<RunId, Uuid>>>,
store: Arc<dyn SessionDataStore>,
handler: F,
) -> InvocationHandle
where
F: Fn(RuntimeEventEnvelope, InvocationSession, Control) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let handler = Arc::new(handler);
let concurrency_limit = control.concurrency_limit();
let semaphore = concurrency_limit.map(|limit| Arc::new(Semaphore::new(limit)));
let task = tokio::spawn(async move {
loop {
match receiver.recv().await {
Ok(event) => {
if control.is_cancelled() {
break;
}
if !kind.matches_agent(&event) {
continue;
}
let run_id = event.run_id();
let session_id = {
let mut map = lock_or_recover(&session_map);
if let AgentEvent::RunStarted(started) = &event {
map.insert(run_id, started.session_id);
Some(started.session_id)
} else {
map.get(&run_id).copied()
}
};
let envelope = RuntimeEventEnvelope {
event_id: super::stream::RuntimeEventId::new(),
seq: 0,
run_id,
session_id,
event,
emitted_at: chrono::Utc::now(),
};
let session = InvocationSession {
session_id,
run_id: Some(run_id),
store: Arc::clone(&store),
};
let permit = if let Some(sem) = semaphore.clone() {
match sem.acquire_owned().await {
Ok(permit) => Some(permit),
Err(_) => break,
}
} else {
None
};
let h = Arc::clone(&handler);
let c = control.clone();
tokio::spawn(async move {
let _permit = permit;
h(envelope, session, c).await;
});
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => {}
}
}
});
InvocationHandle { task }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::event::{RunCompleted, RunStarted, TextDelta};
use behest_provider::{ChatStreamEvent, FinishReason, ModelName, ProviderId, ToolChoice};
use chrono::Utc;
use serde_json::json;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, broadcast};
use uuid::Uuid;
#[test]
fn emit_request_converts_to_run_request() {
let sid = Uuid::new_v4();
let req = EmitRequest::new(ProviderId::new("p"), ModelName::new("m"), "hi")
.with_session_id(sid)
.with_client_request_id("cid")
.with_metadata(json!({"k": "v"}));
let run = req.into_run_request();
assert_eq!(run.provider, ProviderId::new("p"));
assert_eq!(run.model, ModelName::new("m"));
assert_eq!(run.input, "hi");
assert_eq!(run.session_id, Some(sid));
assert_eq!(run.client_request_id.as_deref(), Some("cid"));
assert_eq!(run.metadata, json!({"k": "v"}));
assert!(matches!(run.tool_choice, ToolChoice::Auto));
assert!(run.run_id.is_none());
}
#[test]
fn emit_request_default_metadata_is_null() {
let req = EmitRequest::new(ProviderId::new("p"), ModelName::new("m"), "hi");
assert!(req.metadata.is_null());
let run = req.clone().into_run_request();
assert!(run.metadata.is_null());
}
#[test]
fn event_kind_matches_agent_text_delta() {
let ev = InvocationEvent::Agent(AgentEvent::TextDelta(TextDelta {
run_id: RunId::new(),
delta: "x".into(),
timestamp: Utc::now(),
}));
assert!(EventKind::TextDelta.matches(&ev));
assert!(EventKind::Any.matches(&ev));
assert!(!EventKind::RunCompleted.matches(&ev));
assert!(!EventKind::ChatTextDelta.matches(&ev));
}
#[test]
fn event_kind_matches_agent_run_completed() {
let ev = InvocationEvent::Agent(AgentEvent::RunCompleted(RunCompleted {
run_id: RunId::new(),
finish_reason: FinishReason::Stop,
iterations: 1,
timestamp: Utc::now(),
}));
assert!(EventKind::RunCompleted.matches(&ev));
assert!(EventKind::Any.matches(&ev));
assert!(!EventKind::TextDelta.matches(&ev));
}
#[test]
fn event_kind_matches_chat_text_delta() {
let ev = InvocationEvent::Chat(ChatStreamEvent::TextDelta { delta: "x".into() });
assert!(EventKind::ChatTextDelta.matches(&ev));
assert!(EventKind::Any.matches(&ev));
assert!(!EventKind::TextDelta.matches(&ev));
}
#[test]
fn control_cancel_sets_flag_and_shares_state() {
let c = Control::new();
assert!(!c.is_cancelled());
c.cancel();
assert!(c.is_cancelled());
let cloned = c.clone();
assert!(cloned.is_cancelled(), "clone must share cancel state");
}
#[test]
fn control_set_data_and_data() {
let c = Control::new();
c.set_data(42_u32);
c.set_data(String::from("hello"));
assert_eq!(*c.data::<u32>().unwrap(), 42);
assert_eq!(*c.data::<String>().unwrap(), "hello");
assert!(c.data::<f64>().is_none());
}
#[test]
fn control_data_shared_across_clones() {
let c = Control::new();
c.set_data(99_u64);
let cloned = c.clone();
assert_eq!(*cloned.data::<u64>().unwrap(), 99);
}
#[tokio::test]
async fn memory_session_data_store_round_trip() {
let store = MemorySessionDataStore::new();
let sid = Uuid::new_v4();
store.set(sid, "k".into(), json!({"x": 1})).await.unwrap();
let val = store.get(sid, "k").await.unwrap();
assert_eq!(val, Some(json!({"x": 1})));
store.delete(sid, "k").await.unwrap();
assert!(store.get(sid, "k").await.unwrap().is_none());
}
#[tokio::test]
async fn file_session_data_store_round_trip() {
let dir = std::env::temp_dir().join(format!("behest_test_{}", Uuid::new_v4()));
let store = FileSessionDataStore::new(&dir);
let sid = Uuid::new_v4();
store.set(sid, "name".into(), json!("alice")).await.unwrap();
let val = store.get(sid, "name").await.unwrap();
assert_eq!(val, Some(json!("alice")));
store.delete(sid, "name").await.unwrap();
assert!(store.get(sid, "name").await.unwrap().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn invocation_session_set_get_delete() {
let session = InvocationSession {
session_id: Some(Uuid::new_v4()),
run_id: None,
store: Arc::new(MemorySessionDataStore::new()),
};
session.set_data("key", json!(42)).await.unwrap();
let val = session.get_data("key").await.unwrap();
assert_eq!(val, Some(json!(42)));
session.delete_data("key").await.unwrap();
assert!(session.get_data("key").await.unwrap().is_none());
}
#[tokio::test]
async fn invocation_session_no_session_id_errors() {
let session = InvocationSession {
session_id: None,
run_id: None,
store: Arc::new(MemorySessionDataStore::new()),
};
let result = session.set_data("key", json!(1)).await;
assert!(result.is_err());
}
#[tokio::test]
async fn on_only_handles_matching_events() {
let (tx, rx) = broadcast::channel::<AgentEvent>(16);
let counter = Arc::new(AtomicUsize::new(0));
let c = counter.clone();
let handler = move |_, _, _| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
}
};
let session_map = Arc::new(Mutex::new(HashMap::new()));
let store: Arc<dyn SessionDataStore> = Arc::new(MemorySessionDataStore::new());
let handle = spawn_listener(
rx,
EventKind::TextDelta,
Control::new(),
session_map,
store,
handler,
);
let _ = tx.send(AgentEvent::TextDelta(TextDelta {
run_id: RunId::new(),
delta: "a".into(),
timestamp: Utc::now(),
}));
let _ = tx.send(AgentEvent::RunStarted(RunStarted {
run_id: RunId::new(),
session_id: Uuid::new_v4(),
provider: ProviderId::new("p"),
model: ModelName::new("m"),
timestamp: Utc::now(),
}));
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"only the matching TextDelta event should be handled"
);
handle.abort();
}
#[test]
fn control_set_concurrency_limit_clamps_zero_to_one() {
let control = Control::new();
control.set_concurrency_limit(0);
assert_eq!(control.concurrency_limit(), Some(1));
}
#[tokio::test]
async fn listener_applies_concurrency_backpressure_before_spawning_handlers() {
let (tx, rx) = broadcast::channel::<AgentEvent>(1);
let handled = Arc::new(AtomicUsize::new(0));
let first_started = Arc::new(Notify::new());
let release_first = Arc::new(Notify::new());
let released = Arc::new(AtomicBool::new(false));
let h_handled = Arc::clone(&handled);
let h_first_started = Arc::clone(&first_started);
let h_release_first = Arc::clone(&release_first);
let h_released = Arc::clone(&released);
let handler = move |_, _, _| {
let handled = Arc::clone(&h_handled);
let first_started = Arc::clone(&h_first_started);
let release_first = Arc::clone(&h_release_first);
let released = Arc::clone(&h_released);
async move {
let current = handled.fetch_add(1, Ordering::SeqCst) + 1;
if current == 1 {
first_started.notify_waiters();
release_first.notified().await;
released.store(true, Ordering::SeqCst);
return;
}
if !released.load(Ordering::SeqCst) {
release_first.notified().await;
}
}
};
let session_map = Arc::new(Mutex::new(HashMap::new()));
let store: Arc<dyn SessionDataStore> = Arc::new(MemorySessionDataStore::new());
let control = Control::new();
control.set_concurrency_limit(1);
let handle = spawn_listener(
rx,
EventKind::TextDelta,
control,
session_map,
store,
handler,
);
let _ = tx.send(AgentEvent::TextDelta(TextDelta {
run_id: RunId::new(),
delta: "first".into(),
timestamp: Utc::now(),
}));
tokio::time::timeout(Duration::from_millis(100), first_started.notified())
.await
.expect("first handler should start");
for idx in 0..20 {
let _ = tx.send(AgentEvent::TextDelta(TextDelta {
run_id: RunId::new(),
delta: format!("queued-{idx}"),
timestamp: Utc::now(),
}));
tokio::task::yield_now().await;
}
assert_eq!(handled.load(Ordering::SeqCst), 1);
release_first.notify_waiters();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
handled.load(Ordering::SeqCst) <= 3,
"listener should not pre-spawn handlers while the limit is saturated"
);
handle.abort();
}
#[tokio::test]
async fn invocation_handle_abort_stops_listener() {
let (tx, rx) = broadcast::channel::<AgentEvent>(16);
let counter = Arc::new(AtomicUsize::new(0));
let c = counter.clone();
let handler = move |_, _, _| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
}
};
let session_map = Arc::new(Mutex::new(HashMap::new()));
let store: Arc<dyn SessionDataStore> = Arc::new(MemorySessionDataStore::new());
let handle = spawn_listener(
rx,
EventKind::Any,
Control::new(),
session_map,
store,
handler,
);
let _ = tx.send(AgentEvent::RunStarted(RunStarted {
run_id: RunId::new(),
session_id: Uuid::new_v4(),
provider: ProviderId::new("p"),
model: ModelName::new("m"),
timestamp: Utc::now(),
}));
tokio::time::sleep(Duration::from_millis(100)).await;
let before = counter.load(Ordering::SeqCst);
assert!(before >= 1, "first event should be handled");
handle.abort();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(handle.is_finished(), "listener should finish after abort");
let _ = tx.send(AgentEvent::RunStarted(RunStarted {
run_id: RunId::new(),
session_id: Uuid::new_v4(),
provider: ProviderId::new("p"),
model: ModelName::new("m"),
timestamp: Utc::now(),
}));
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
counter.load(Ordering::SeqCst),
before,
"no events should be handled after abort"
);
}
}