Skip to main content

behest_runtime/
invocation.rs

1//! Transport-neutral runtime invocation facade.
2//!
3//! This module exposes a Socket.IO-inspired `emit` / `on` interaction model
4//! over [`AgentRuntime`] without binding to any wire protocol. Transport
5//! adapters (Socket.IO, gRPC, SSE, ...) build on top of these types; the core
6//! runtime stays free of transport concerns.
7//!
8//! # emit / on semantics
9//!
10//! [`emit`](RuntimeInvocation::emit) starts a run — it builds an
11//! [`EmitRequest`], converts it to a [`RunRequest`], and hands it to
12//! [`AgentRuntime::run`]. Returns [`RunOutput`] when the run completes.
13//!
14//! [`on`](RuntimeInvocation::on) subscribes to the runtime's event bus.
15//! Every event from _any_ run managed by the same [`AgentRuntime`] is
16//! received; the [`EventKind`] filter discards non-matching events before
17//! the handler fires. Handlers run on independently spawned tasks so a
18//! slow handler does not block event delivery for other subscribers.
19//!
20//! ## Event ordering
21//!
22//! A typical agent run produces events in this order:
23//!
24//! ```text
25//! RunStarted
26//!   → ContextBuilt
27//!   → ModelStarted → TextDelta* → ToolCallStarted? → ToolCallDelta* →
28//!     ToolCallCompleted? → ToolExecutionStarted? → ToolExecutionFinished?
29//!   → AssistantMessageCommitted → UsageRecorded
30//!   → (loop: ContextBuilt → …)
31//!   → RunCompleted | RunFailed | RunCancelled
32//! ```
33//!
34//! Every event carries a [`run_id`](AgentEvent::run_id) so handlers can
35//! correlate events belonging to the same run.
36//!
37//! ## Event delivery guarantees
38//!
39//! - **At-least-once**: events are delivered via a [`tokio::sync::broadcast`]
40//!   channel. Lagging receivers miss events (signaled via
41//!   [`tokio::sync::broadcast::error::RecvError::Lagged`]), which is
42//!   silently skipped — the receiver misses those events.
43//! - **No backpressure**: `emit` does not wait for `on` handlers to
44//!   complete. The runtime publishes events to the broadcast channel
45//!   and proceeds immediately.
46//! - **Ordered per-run**: events from a single run are always published
47//!   in order. Events from concurrent runs may interleave.
48//! - **Handler concurrency**: if [`Control::set_concurrency_limit`] is set,
49//!   a semaphore gates how many handler tasks may run concurrently.
50//!   Without a limit, every matching event spawns an unbounded task.
51//!
52//! ## Handler lifecycle
53//!
54//! [`on`](RuntimeInvocation::on) returns an [`InvocationHandle`]. The
55//! underlying listener task runs until:
56//!
57//! - The [`InvocationHandle`] is dropped (aborts the task), or
58//! - The broadcast channel is closed (all [`AgentRuntime`] senders dropped).
59//!
60//! Handler tasks already dispatched before the listener stops run to
61//! completion.
62//!
63//! ## Cancellation
64//!
65//! Cancellation is cooperative. [`Control::cancel`] sets a flag.
66//! [`emit`](RuntimeInvocation::emit) checks the flag before invoking the
67//! request closure and before calling [`AgentRuntime::run`]; the listener
68//! loop inside [`on`](RuntimeInvocation::on) checks it before every event
69//! dispatch. The underlying runtime does not yet support hard cancellation
70//! of an in-flight model call.
71//!
72//! # Core surface
73//!
74//! - [`EmitRequest`] builds a run request and hands it to [`AgentRuntime::run`].
75//! - [`RuntimeInvocation::on`] subscribes to the runtime event bus and dispatches
76//!   matching events to user handlers on independent tasks.
77//! - [`Control`] carries cooperative cancellation/timeout/concurrency state,
78//!   plus a type-erased extension map for injecting shared data into handlers.
79//! - [`InvocationHandle`] aborts its listener on drop.
80//!
81//! # Transport-adapter surface
82//!
83//! [`InvocationEvent::Chat`] and the `Chat*` variants of [`EventKind`] are
84//! defined here so adapters reuse the same matching logic, but the core
85//! `on` implementation only surfaces [`AgentEvent`]s from
86//! [`AgentRuntime::subscribe`]; chat-stream events require a streaming
87//! chat adapter that populates the `Chat` variant.
88
89use std::any::{Any, TypeId};
90use std::collections::HashMap;
91use std::fmt;
92use std::future::Future;
93use std::path::PathBuf;
94use std::sync::atomic::{AtomicBool, Ordering};
95use std::sync::{Arc, Mutex};
96use std::time::Duration;
97
98use async_trait::async_trait;
99use serde_json::Value;
100use thiserror::Error;
101use tokio::sync::Semaphore;
102use tokio::sync::broadcast;
103use tokio::task::JoinHandle;
104use uuid::Uuid;
105
106use super::agent::{AgentRuntime, RunOutput};
107use super::error::RuntimeError;
108use super::event::AgentEvent;
109use super::run::{RunId, RunRequest};
110use super::stream::RuntimeEventEnvelope;
111use behest_provider::{ChatStreamEvent, ModelName, ProviderId, ToolChoice};
112
113/// Transport-neutral request for a single runtime invocation.
114///
115/// Converts to [`RunRequest`] via [`EmitRequest::into_run_request`]. The
116/// invocation layer deliberately omits `tool_choice` and `run_id`: callers
117/// needing fine-grained control over those should construct a [`RunRequest`]
118/// directly and call [`AgentRuntime::run`].
119#[derive(Debug, Clone)]
120pub struct EmitRequest {
121    /// Provider used for model calls.
122    pub provider: ProviderId,
123    /// Model used for generation.
124    pub model: ModelName,
125    /// User input message.
126    pub input: String,
127    /// Optional session id. When `None`, the runtime creates a new session.
128    pub session_id: Option<Uuid>,
129    /// Optional client-provided idempotency key.
130    pub client_request_id: Option<String>,
131    /// Arbitrary metadata attached to the run. Defaults to [`Value::Null`].
132    pub metadata: Value,
133}
134
135impl EmitRequest {
136    /// Creates a new emit request with no session, no client id, and null metadata.
137    #[must_use]
138    pub fn new(provider: ProviderId, model: ModelName, input: impl Into<String>) -> Self {
139        Self {
140            provider,
141            model,
142            input: input.into(),
143            session_id: None,
144            client_request_id: None,
145            metadata: Value::Null,
146        }
147    }
148
149    /// Sets the session id.
150    #[must_use]
151    pub fn with_session_id(mut self, session_id: Uuid) -> Self {
152        self.session_id = Some(session_id);
153        self
154    }
155
156    /// Sets the client-provided idempotency key.
157    #[must_use]
158    pub fn with_client_request_id(mut self, id: impl Into<String>) -> Self {
159        self.client_request_id = Some(id.into());
160        self
161    }
162
163    /// Sets the metadata payload.
164    #[must_use]
165    pub fn with_metadata(mut self, metadata: Value) -> Self {
166        self.metadata = metadata;
167        self
168    }
169
170    /// Converts this request into a [`RunRequest`] consumed by the runtime.
171    ///
172    /// `tool_choice` defaults to [`ToolChoice::Auto`] and `run_id` is left
173    /// unset so the runtime allocates one.
174    #[must_use]
175    pub fn into_run_request(self) -> RunRequest {
176        RunRequest {
177            session_id: self.session_id,
178            run_id: None,
179            provider: self.provider,
180            model: self.model,
181            input: self.input,
182            metadata: self.metadata,
183            tool_choice: ToolChoice::Auto,
184            client_request_id: self.client_request_id,
185        }
186    }
187}
188
189/// Errors raised by the invocation facade.
190#[derive(Debug, Error)]
191pub enum InvocationError {
192    /// Underlying runtime error, propagated from [`AgentRuntime::run`].
193    #[error(transparent)]
194    Runtime(#[from] RuntimeError),
195
196    /// Invocation task failed (for example, cancelled before completion).
197    #[error("invocation task failed: {message}")]
198    TaskFailed {
199        /// Human-readable failure reason.
200        message: String,
201    },
202
203    /// Invalid invocation request.
204    #[error("invalid invocation request: {message}")]
205    InvalidRequest {
206        /// Human-readable validation reason.
207        message: String,
208    },
209}
210
211/// Errors returned by [`SessionDataStore`] implementations.
212#[derive(Debug, Error)]
213pub enum SessionDataError {
214    /// The requested session data key was not found.
215    #[error("session data key not found: {session_id}/{key}")]
216    NotFound {
217        /// Session id.
218        session_id: Uuid,
219        /// Key that was not found.
220        key: String,
221    },
222    /// Underlying storage backend error.
223    #[error("session data storage error: {message}")]
224    Storage {
225        /// Human-readable error description.
226        message: String,
227    },
228}
229
230/// Pluggable backend for per-session temporary key-value data.
231///
232/// Implementations store ephemeral data associated with a session id.
233/// The trait is async so backends like Redis can perform non-blocking I/O.
234///
235/// # Built-in implementations
236///
237/// - [`MemorySessionDataStore`] — in-process `HashMap` (default)
238/// - [`FileSessionDataStore`] — JSON files on disk (no external deps)
239/// - [`RedisSessionDataStore`](crate::session_data_store::RedisSessionDataStore) — Redis hashes (feature = `redis`)
240#[async_trait]
241pub trait SessionDataStore: Send + Sync {
242    /// Stores a value under `(session_id, key)`, overwriting any existing value.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`SessionDataError::Storage`] on backend failure.
247    async fn set(
248        &self,
249        session_id: Uuid,
250        key: String,
251        value: Value,
252    ) -> Result<(), SessionDataError>;
253
254    /// Retrieves the value stored under `(session_id, key)`.
255    ///
256    /// Returns `Ok(None)` when the key does not exist.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`SessionDataError::Storage`] on backend failure.
261    async fn get(&self, session_id: Uuid, key: &str) -> Result<Option<Value>, SessionDataError>;
262
263    /// Deletes the value stored under `(session_id, key)`.
264    ///
265    /// Deleting a non-existent key is a no-op (no error).
266    ///
267    /// # Errors
268    ///
269    /// Returns [`SessionDataError::Storage`] on backend failure.
270    async fn delete(&self, session_id: Uuid, key: &str) -> Result<(), SessionDataError>;
271}
272
273/// In-memory [`SessionDataStore`] backed by a `HashMap`.
274///
275/// Suitable for single-process deployments and testing. Data does not
276/// survive process restarts.
277#[derive(Clone)]
278pub struct MemorySessionDataStore {
279    data: Arc<Mutex<HashMap<(Uuid, String), Value>>>,
280}
281
282impl Default for MemorySessionDataStore {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288impl MemorySessionDataStore {
289    /// Creates a new empty in-memory store.
290    #[must_use]
291    pub fn new() -> Self {
292        Self {
293            data: Arc::new(Mutex::new(HashMap::new())),
294        }
295    }
296}
297
298impl fmt::Debug for MemorySessionDataStore {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        f.debug_struct("MemorySessionDataStore")
301            .finish_non_exhaustive()
302    }
303}
304
305#[async_trait]
306impl SessionDataStore for MemorySessionDataStore {
307    async fn set(
308        &self,
309        session_id: Uuid,
310        key: String,
311        value: Value,
312    ) -> Result<(), SessionDataError> {
313        let mut map = self
314            .data
315            .lock()
316            .unwrap_or_else(std::sync::PoisonError::into_inner);
317        map.insert((session_id, key), value);
318        Ok(())
319    }
320
321    async fn get(&self, session_id: Uuid, key: &str) -> Result<Option<Value>, SessionDataError> {
322        let map = self
323            .data
324            .lock()
325            .unwrap_or_else(std::sync::PoisonError::into_inner);
326        Ok(map.get(&(session_id, key.to_string())).cloned())
327    }
328
329    async fn delete(&self, session_id: Uuid, key: &str) -> Result<(), SessionDataError> {
330        let mut map = self
331            .data
332            .lock()
333            .unwrap_or_else(std::sync::PoisonError::into_inner);
334        map.remove(&(session_id, key.to_string()));
335        Ok(())
336    }
337}
338
339/// File-system [`SessionDataStore`] backed by JSON files.
340///
341/// Each session's data is stored in a single JSON file under the configured
342/// directory. No external dependencies — uses `tokio::task::spawn_blocking`
343/// with `std::fs` for blocking I/O. Per-session locking prevents concurrent
344/// writes to the same file.
345pub struct FileSessionDataStore {
346    base_dir: PathBuf,
347    locks: Arc<Mutex<HashMap<Uuid, Arc<Mutex<()>>>>>,
348}
349
350impl FileSessionDataStore {
351    /// Creates a new file-backed store rooted at `base_dir`.
352    ///
353    /// The directory is created lazily on the first `set` call.
354    #[must_use]
355    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
356        Self {
357            base_dir: base_dir.into(),
358            locks: Arc::new(Mutex::new(HashMap::new())),
359        }
360    }
361
362    fn session_path(&self, session_id: Uuid) -> PathBuf {
363        self.base_dir.join(format!("{session_id}.json"))
364    }
365
366    fn session_lock(&self, session_id: Uuid) -> Arc<Mutex<()>> {
367        let mut map = self
368            .locks
369            .lock()
370            .unwrap_or_else(std::sync::PoisonError::into_inner);
371        map.entry(session_id)
372            .or_insert_with(|| Arc::new(Mutex::new(())))
373            .clone()
374    }
375}
376
377impl fmt::Debug for FileSessionDataStore {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        f.debug_struct("FileSessionDataStore")
380            .field("base_dir", &self.base_dir)
381            .finish_non_exhaustive()
382    }
383}
384
385#[async_trait]
386impl SessionDataStore for FileSessionDataStore {
387    async fn set(
388        &self,
389        session_id: Uuid,
390        key: String,
391        value: Value,
392    ) -> Result<(), SessionDataError> {
393        let path = self.session_path(session_id);
394        let lock = self.session_lock(session_id);
395        let base = self.base_dir.clone();
396
397        tokio::task::spawn_blocking(move || {
398            let _guard = lock
399                .lock()
400                .unwrap_or_else(std::sync::PoisonError::into_inner);
401            std::fs::create_dir_all(&base).map_err(|e| SessionDataError::Storage {
402                message: format!("failed to create base dir: {e}"),
403            })?;
404
405            let mut map: HashMap<String, Value> = if path.exists() {
406                let data =
407                    std::fs::read_to_string(&path).map_err(|e| SessionDataError::Storage {
408                        message: format!("failed to read session file: {e}"),
409                    })?;
410                serde_json::from_str(&data).map_err(|e| SessionDataError::Storage {
411                    message: format!("failed to parse session file: {e}"),
412                })?
413            } else {
414                HashMap::new()
415            };
416
417            map.insert(key, value);
418            let json =
419                serde_json::to_string_pretty(&map).map_err(|e| SessionDataError::Storage {
420                    message: format!("failed to serialize session data: {e}"),
421                })?;
422            std::fs::write(&path, json).map_err(|e| SessionDataError::Storage {
423                message: format!("failed to write session file: {e}"),
424            })?;
425            Ok(())
426        })
427        .await
428        .map_err(|e| SessionDataError::Storage {
429            message: format!("spawn_blocking error: {e}"),
430        })?
431    }
432
433    async fn get(&self, session_id: Uuid, key: &str) -> Result<Option<Value>, SessionDataError> {
434        let path = self.session_path(session_id);
435        let lock = self.session_lock(session_id);
436        let key = key.to_string();
437
438        tokio::task::spawn_blocking(move || {
439            let _guard = lock
440                .lock()
441                .unwrap_or_else(std::sync::PoisonError::into_inner);
442            if !path.exists() {
443                return Ok(None);
444            }
445            let data = std::fs::read_to_string(&path).map_err(|e| SessionDataError::Storage {
446                message: format!("failed to read session file: {e}"),
447            })?;
448            let map: HashMap<String, Value> =
449                serde_json::from_str(&data).map_err(|e| SessionDataError::Storage {
450                    message: format!("failed to parse session file: {e}"),
451                })?;
452            Ok(map.get(&key).cloned())
453        })
454        .await
455        .map_err(|e| SessionDataError::Storage {
456            message: format!("spawn_blocking error: {e}"),
457        })?
458    }
459
460    async fn delete(&self, session_id: Uuid, key: &str) -> Result<(), SessionDataError> {
461        let path = self.session_path(session_id);
462        let lock = self.session_lock(session_id);
463        let key = key.to_string();
464
465        tokio::task::spawn_blocking(move || {
466            let _guard = lock
467                .lock()
468                .unwrap_or_else(std::sync::PoisonError::into_inner);
469            if !path.exists() {
470                return Ok(());
471            }
472            let data = std::fs::read_to_string(&path).map_err(|e| SessionDataError::Storage {
473                message: format!("failed to read session file: {e}"),
474            })?;
475            let mut map: HashMap<String, Value> =
476                serde_json::from_str(&data).map_err(|e| SessionDataError::Storage {
477                    message: format!("failed to parse session file: {e}"),
478                })?;
479            map.remove(&key);
480            let json =
481                serde_json::to_string_pretty(&map).map_err(|e| SessionDataError::Storage {
482                    message: format!("failed to serialize session data: {e}"),
483                })?;
484            std::fs::write(&path, json).map_err(|e| SessionDataError::Storage {
485                message: format!("failed to write session file: {e}"),
486            })?;
487            Ok(())
488        })
489        .await
490        .map_err(|e| SessionDataError::Storage {
491            message: format!("spawn_blocking error: {e}"),
492        })?
493    }
494}
495
496/// Unified event envelope wrapping runtime and chat-stream events.
497///
498/// The core `on` loop only produces the [`Agent`](InvocationEvent::Agent)
499/// variant from [`AgentRuntime::subscribe`]. The [`Chat`](InvocationEvent::Chat)
500/// variant is populated by transport adapters that surface provider streams.
501#[derive(Debug, Clone)]
502pub enum InvocationEvent {
503    /// Event from the agent runtime event bus.
504    Agent(AgentEvent),
505    /// Event from a chat stream. Populated by transport adapters.
506    Chat(ChatStreamEvent),
507}
508
509impl InvocationEvent {
510    /// Returns the run id when derivable from an [`AgentEvent`].
511    ///
512    /// Chat-stream events carry no run id; `None` is returned for them.
513    #[must_use]
514    pub fn run_id(&self) -> Option<RunId> {
515        match self {
516            InvocationEvent::Agent(e) => Some(e.run_id()),
517            InvocationEvent::Chat(_) => None,
518        }
519    }
520
521    /// Returns the wrapped [`AgentEvent`] when this is the [`Agent`](Self::Agent) variant.
522    #[must_use]
523    pub fn as_agent(&self) -> Option<&AgentEvent> {
524        match self {
525            InvocationEvent::Agent(e) => Some(e),
526            InvocationEvent::Chat(_) => None,
527        }
528    }
529}
530
531/// Kind of event a caller can subscribe to via [`RuntimeInvocation::on`].
532///
533/// Variants are partitioned into agent-runtime events (mirroring [`AgentEvent`])
534/// and chat-stream events (mirroring [`ChatStreamEvent`]). [`EventKind::Any`]
535/// matches every event.
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
537pub enum EventKind {
538    /// Matches any event.
539    Any,
540    /// Agent run started.
541    RunStarted,
542    /// Context built.
543    ContextBuilt,
544    /// Model call started.
545    ModelStarted,
546    /// Text delta from model.
547    TextDelta,
548    /// Tool call started.
549    ToolCallStarted,
550    /// Tool call arguments delta.
551    ToolCallDelta,
552    /// Tool call completed.
553    ToolCallCompleted,
554    /// Tool execution started.
555    ToolExecutionStarted,
556    /// Tool execution finished.
557    ToolExecutionFinished,
558    /// Assistant message committed to store.
559    AssistantMessageCommitted,
560    /// Tool message committed to store.
561    ToolMessageCommitted,
562    /// Usage recorded.
563    UsageRecorded,
564    /// Prompt cache metrics from a single model call.
565    CacheMetrics,
566    /// Run completed successfully.
567    RunCompleted,
568    /// Run failed.
569    RunFailed,
570    /// Run cancelled.
571    RunCancelled,
572    /// Doom loop detected.
573    DoomLoopDetected,
574    /// Compaction circuit breaker opened.
575    CompactionCircuitOpened,
576    /// Chat stream started.
577    ChatStarted,
578    /// Chat stream text delta.
579    ChatTextDelta,
580    /// Chat stream tool call started.
581    ChatToolCallStarted,
582    /// Chat stream tool call arguments delta.
583    ChatToolCallArgumentsDelta,
584    /// Chat stream tool call completed.
585    ChatToolCallCompleted,
586    /// Chat stream finished.
587    ChatFinished,
588}
589
590impl EventKind {
591    /// Returns `true` when `event` matches this kind.
592    #[must_use]
593    #[allow(clippy::too_many_lines)]
594    pub fn matches_agent(self, event: &AgentEvent) -> bool {
595        match self {
596            Self::Any => true,
597            Self::RunStarted => matches!(event, AgentEvent::RunStarted(_)),
598            Self::ContextBuilt => matches!(event, AgentEvent::ContextBuilt(_)),
599            Self::ModelStarted => matches!(event, AgentEvent::ModelStarted(_)),
600            Self::TextDelta => matches!(event, AgentEvent::TextDelta(_)),
601            Self::ToolCallStarted => matches!(event, AgentEvent::ToolCallStarted(_)),
602            Self::ToolCallDelta => matches!(event, AgentEvent::ToolCallDelta(_)),
603            Self::ToolCallCompleted => matches!(event, AgentEvent::ToolCallCompleted(_)),
604            Self::ToolExecutionStarted => matches!(event, AgentEvent::ToolExecutionStarted(_)),
605            Self::ToolExecutionFinished => matches!(event, AgentEvent::ToolExecutionFinished(_)),
606            Self::AssistantMessageCommitted => {
607                matches!(event, AgentEvent::AssistantMessageCommitted(_))
608            }
609            Self::ToolMessageCommitted => matches!(event, AgentEvent::ToolMessageCommitted(_)),
610            Self::UsageRecorded => matches!(event, AgentEvent::UsageRecorded(_)),
611            Self::CacheMetrics => matches!(event, AgentEvent::CacheMetrics(_)),
612            Self::RunCompleted => matches!(event, AgentEvent::RunCompleted(_)),
613            Self::RunFailed => matches!(event, AgentEvent::RunFailed(_)),
614            Self::RunCancelled => matches!(event, AgentEvent::RunCancelled(_)),
615            Self::DoomLoopDetected => matches!(event, AgentEvent::DoomLoopDetected(_)),
616            Self::CompactionCircuitOpened => {
617                matches!(event, AgentEvent::CompactionCircuitOpened(_))
618            }
619            // Chat variants never match raw AgentEvent
620            Self::ChatStarted
621            | Self::ChatTextDelta
622            | Self::ChatToolCallStarted
623            | Self::ChatToolCallArgumentsDelta
624            | Self::ChatToolCallCompleted
625            | Self::ChatFinished => false,
626        }
627    }
628
629    /// Returns `true` when `event` matches this kind.
630    #[must_use]
631    pub fn matches(self, event: &InvocationEvent) -> bool {
632        match event {
633            InvocationEvent::Agent(e) => self.matches_agent(e),
634            InvocationEvent::Chat(e) => self.matches_chat(e),
635        }
636    }
637
638    /// Returns `true` when `event` matches this kind's chat variant.
639    #[must_use]
640    fn matches_chat(self, event: &ChatStreamEvent) -> bool {
641        match self {
642            Self::Any => true,
643            Self::ChatStarted => matches!(event, ChatStreamEvent::Started { .. }),
644            Self::ChatTextDelta => matches!(event, ChatStreamEvent::TextDelta { .. }),
645            Self::ChatToolCallStarted => matches!(event, ChatStreamEvent::ToolCallStarted { .. }),
646            Self::ChatToolCallArgumentsDelta => {
647                matches!(event, ChatStreamEvent::ToolCallArgumentsDelta { .. })
648            }
649            Self::ChatToolCallCompleted => {
650                matches!(event, ChatStreamEvent::ToolCallCompleted { .. })
651            }
652            Self::ChatFinished => matches!(event, ChatStreamEvent::Finished { .. }),
653            _ => false,
654        }
655    }
656}
657
658/// Invocation-time session context with temporary KV storage.
659///
660/// Passed as the second argument to `on` handlers and the first argument to
661/// `emit` closures. Carries `session_id`, `run_id`, and a pluggable
662/// [`SessionDataStore`] backend for ephemeral per-session data.
663pub struct InvocationSession {
664    /// Session id, when known.
665    pub session_id: Option<Uuid>,
666    /// Run id, when known.
667    pub run_id: Option<RunId>,
668    store: Arc<dyn SessionDataStore>,
669}
670
671impl InvocationSession {
672    /// Stores a temporary value in the session data store.
673    ///
674    /// # Errors
675    ///
676    /// Returns [`SessionDataError`] when the session id is unknown or the
677    /// backend fails.
678    pub async fn set_data(
679        &self,
680        key: impl Into<String>,
681        value: Value,
682    ) -> Result<(), SessionDataError> {
683        let session_id = self.session_id.ok_or(SessionDataError::Storage {
684            message: "session_id not available".into(),
685        })?;
686        self.store.set(session_id, key.into(), value).await
687    }
688
689    /// Retrieves a temporary value from the session data store.
690    ///
691    /// Returns `Ok(None)` when the key does not exist.
692    ///
693    /// # Errors
694    ///
695    /// Returns [`SessionDataError`] when the session id is unknown or the
696    /// backend fails.
697    pub async fn get_data(&self, key: &str) -> Result<Option<Value>, SessionDataError> {
698        let session_id = self.session_id.ok_or(SessionDataError::Storage {
699            message: "session_id not available".into(),
700        })?;
701        self.store.get(session_id, key).await
702    }
703
704    /// Deletes a temporary value from the session data store.
705    ///
706    /// Deleting a non-existent key is a no-op.
707    ///
708    /// # Errors
709    ///
710    /// Returns [`SessionDataError`] when the session id is unknown or the
711    /// backend fails.
712    pub async fn delete_data(&self, key: &str) -> Result<(), SessionDataError> {
713        let session_id = self.session_id.ok_or(SessionDataError::Storage {
714            message: "session_id not available".into(),
715        })?;
716        self.store.delete(session_id, key).await
717    }
718}
719
720impl fmt::Debug for InvocationSession {
721    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
722        f.debug_struct("InvocationSession")
723            .field("session_id", &self.session_id)
724            .field("run_id", &self.run_id)
725            .finish_non_exhaustive()
726    }
727}
728
729#[derive(Debug)]
730struct ControlInner {
731    cancelled: AtomicBool,
732    timeout: Mutex<Option<Duration>>,
733    concurrency_limit: Mutex<Option<usize>>,
734    extensions: Mutex<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
735}
736
737/// Cooperative lifecycle handle shared across an invocation.
738///
739/// Cloning a `Control` shares the same cancellation and limit state. Cancellation
740/// is cooperative: [`RuntimeInvocation::emit`] checks [`Control::is_cancelled`]
741/// before invoking the closure and before calling [`AgentRuntime::run`]; the
742/// underlying runtime does not yet support hard cancellation. Timeout and
743/// concurrency limit are enforced by event listeners spawned through
744/// [`RuntimeInvocation::on`].
745///
746/// ## Type-erased data
747///
748/// `Control` also carries an extension map for injecting arbitrary typed data
749/// into handlers (similar to `actix-web::web::Data<T>`). Use [`Control::set_data`]
750/// and [`Control::data`] to store and retrieve `Arc<T>` values keyed by `TypeId`.
751#[derive(Debug, Clone)]
752pub struct Control {
753    inner: Arc<ControlInner>,
754}
755
756impl Default for Control {
757    fn default() -> Self {
758        Self::new()
759    }
760}
761
762impl Control {
763    /// Creates a fresh control handle: not cancelled, no timeout, no limit.
764    #[must_use]
765    pub fn new() -> Self {
766        Self {
767            inner: Arc::new(ControlInner {
768                cancelled: AtomicBool::new(false),
769                timeout: Mutex::new(None),
770                concurrency_limit: Mutex::new(None),
771                extensions: Mutex::new(HashMap::new()),
772            }),
773        }
774    }
775
776    /// Marks this invocation as cancelled.
777    pub fn cancel(&self) {
778        self.inner.cancelled.store(true, Ordering::Release);
779    }
780
781    /// Returns `true` once [`cancel`](Self::cancel) has been called on any clone.
782    #[must_use]
783    pub fn is_cancelled(&self) -> bool {
784        self.inner.cancelled.load(Ordering::Acquire)
785    }
786
787    /// Sets a provider/runtime call timeout hint.
788    pub fn set_timeout(&self, timeout: Duration) {
789        *lock_or_recover(&self.inner.timeout) = Some(timeout);
790    }
791
792    /// Returns the configured timeout hint, if any.
793    #[must_use]
794    pub fn timeout(&self) -> Option<Duration> {
795        *lock_or_recover(&self.inner.timeout)
796    }
797
798    /// Sets a concurrency limit hint.
799    pub fn set_concurrency_limit(&self, limit: usize) {
800        *lock_or_recover(&self.inner.concurrency_limit) = Some(limit.max(1));
801    }
802
803    /// Returns the configured concurrency limit hint, if any.
804    #[must_use]
805    pub fn concurrency_limit(&self) -> Option<usize> {
806        *lock_or_recover(&self.inner.concurrency_limit)
807    }
808
809    /// Stores a type-erased value in the extension map.
810    ///
811    /// Values are keyed by [`TypeId`]; storing a second value of the same
812    /// type overwrites the first.
813    pub fn set_data<T: Send + Sync + 'static>(&self, val: T) {
814        let mut ext = lock_or_recover(&self.inner.extensions);
815        ext.insert(TypeId::of::<T>(), Arc::new(val));
816    }
817
818    /// Retrieves a previously stored value by type.
819    ///
820    /// Returns `None` when no value of type `T` has been stored.
821    #[must_use]
822    pub fn data<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
823        let ext = lock_or_recover(&self.inner.extensions);
824        ext.get(&TypeId::of::<T>())
825            .and_then(|arc| Arc::clone(arc).downcast::<T>().ok())
826    }
827}
828
829/// Recovers from mutex poison by taking the inner guard, avoiding `unwrap`/`expect`.
830fn lock_or_recover<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
831    lock.lock()
832        .unwrap_or_else(std::sync::PoisonError::into_inner)
833}
834
835/// Handle to a background listener task started by [`RuntimeInvocation::on`].
836///
837/// Dropping this handle aborts the listener task to prevent leaks. Inner
838/// handler tasks already dispatched before drop run to completion.
839#[derive(Debug)]
840pub struct InvocationHandle {
841    task: JoinHandle<()>,
842}
843
844impl InvocationHandle {
845    /// Aborts the listener task. Idempotent.
846    pub fn abort(&self) {
847        self.task.abort();
848    }
849
850    /// Returns `true` once the listener task has finished (completed or aborted).
851    #[must_use]
852    pub fn is_finished(&self) -> bool {
853        self.task.is_finished()
854    }
855}
856
857impl Drop for InvocationHandle {
858    fn drop(&mut self) {
859        self.task.abort();
860    }
861}
862
863/// Transport-neutral invocation facade over [`AgentRuntime`].
864///
865/// Wraps an `Arc<AgentRuntime>` and exposes `emit` / `on` semantics without
866/// pulling in any wire protocol. Transport adapters build on top of this.
867#[derive(Clone)]
868pub struct RuntimeInvocation {
869    runtime: Arc<AgentRuntime>,
870    session_map: Arc<Mutex<HashMap<RunId, Uuid>>>,
871    initial_data: Arc<Mutex<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>>,
872    session_data_store: Arc<dyn SessionDataStore>,
873}
874
875impl RuntimeInvocation {
876    /// Wraps a runtime in the invocation facade.
877    ///
878    /// Uses [`MemorySessionDataStore`] as the default session data backend.
879    #[must_use]
880    pub fn new(runtime: Arc<AgentRuntime>) -> Self {
881        Self {
882            runtime,
883            session_map: Arc::new(Mutex::new(HashMap::new())),
884            initial_data: Arc::new(Mutex::new(HashMap::new())),
885            session_data_store: Arc::new(MemorySessionDataStore::new()),
886        }
887    }
888
889    /// Wraps a runtime with a custom [`SessionDataStore`] backend.
890    #[must_use]
891    pub fn with_session_store(
892        runtime: Arc<AgentRuntime>,
893        store: Arc<dyn SessionDataStore>,
894    ) -> Self {
895        Self {
896            runtime,
897            session_map: Arc::new(Mutex::new(HashMap::new())),
898            initial_data: Arc::new(Mutex::new(HashMap::new())),
899            session_data_store: store,
900        }
901    }
902
903    /// Returns a reference to the underlying runtime.
904    #[must_use]
905    pub fn runtime(&self) -> &AgentRuntime {
906        &self.runtime
907    }
908
909    /// Registers a typed value to be injected into every [`Control`] created
910    /// by [`emit`](Self::emit) and [`on`](Self::on).
911    ///
912    /// This is analogous to `actix-web::web::Data<T>` — call it once during
913    /// setup, and every handler can retrieve the value via [`Control::data`].
914    pub fn set_data<T: Send + Sync + 'static>(&self, val: T) {
915        let mut map = lock_or_recover(&self.initial_data);
916        map.insert(TypeId::of::<T>(), Arc::new(val));
917    }
918
919    /// Creates a [`Control`] pre-filled with [`initial_data`](Self::set_data).
920    fn make_control(&self) -> Control {
921        let control = Control::new();
922        let extensions = lock_or_recover(&self.initial_data);
923        let mut target = lock_or_recover(&control.inner.extensions);
924        for (type_id, arc) in extensions.iter() {
925            target.insert(*type_id, Arc::clone(arc));
926        }
927        drop(target);
928        drop(extensions);
929        control
930    }
931
932    /// Emits a run request built by `f` and executes it via [`AgentRuntime::run`].
933    ///
934    /// The closure receives an [`InvocationSession`] (with no session known yet)
935    /// and a fresh [`Control`] handle, then returns an [`EmitRequest`]. The
936    /// request is converted to a [`RunRequest`] and handed to the runtime.
937    ///
938    /// Cancellation is cooperative: [`Control::is_cancelled`] is checked before
939    /// the closure runs and before `run` is called. If cancelled,
940    /// [`InvocationError::TaskFailed`] is returned. Because `Control` is created
941    /// internally, external cancellation of `emit` is not exposed by the core
942    /// API — transport adapters wrap `emit` to provide that.
943    ///
944    /// # Errors
945    ///
946    /// Returns [`InvocationError::TaskFailed`] when cancelled, or
947    /// [`InvocationError::Runtime`] when the underlying run fails.
948    pub async fn emit<F, Fut>(&self, f: F) -> Result<RunOutput, InvocationError>
949    where
950        F: FnOnce(InvocationSession, Control) -> Fut + Send,
951        Fut: Future<Output = EmitRequest> + Send,
952    {
953        let control = self.make_control();
954        let session = InvocationSession {
955            session_id: None,
956            run_id: None,
957            store: Arc::clone(&self.session_data_store),
958        };
959        if control.is_cancelled() {
960            return Err(InvocationError::TaskFailed {
961                message: "cancelled".into(),
962            });
963        }
964        let request = f(session, control.clone()).await;
965        if control.is_cancelled() {
966            return Err(InvocationError::TaskFailed {
967                message: "cancelled".into(),
968            });
969        }
970        let run_request = request.into_run_request();
971        let output = self.runtime.run(run_request).await?;
972        Ok(output)
973    }
974
975    /// Registers an event handler for events matching `kind`.
976    ///
977    /// Events are sourced from [`AgentRuntime::subscribe`]. When an event
978    /// matches, the handler runs on a freshly spawned task so slow handlers
979    /// do not block event reception. The returned [`InvocationHandle`] aborts
980    /// the listener on drop.
981    ///
982    /// # Errors
983    ///
984    /// Returns [`InvocationError::InvalidRequest`] only if subscription setup
985    /// is rejected. In the current implementation this always succeeds.
986    #[allow(clippy::unused_async)]
987    pub async fn on<F, Fut>(
988        &self,
989        kind: EventKind,
990        f: F,
991    ) -> Result<InvocationHandle, InvocationError>
992    where
993        F: Fn(RuntimeEventEnvelope, InvocationSession, Control) -> Fut + Send + Sync + 'static,
994        Fut: Future<Output = ()> + Send + 'static,
995    {
996        let receiver = self.runtime.subscribe();
997        let control = self.make_control();
998        let session_map = Arc::clone(&self.session_map);
999        let store = Arc::clone(&self.session_data_store);
1000        Ok(spawn_listener(
1001            receiver,
1002            kind,
1003            control,
1004            session_map,
1005            store,
1006            f,
1007        ))
1008    }
1009}
1010
1011/// Spawns the event-reception loop backing [`RuntimeInvocation::on`].
1012///
1013/// Kept as a free function so tests can drive it with a synthetic
1014/// [`broadcast::Receiver`] without constructing a full [`AgentRuntime`].
1015fn spawn_listener<F, Fut>(
1016    mut receiver: broadcast::Receiver<AgentEvent>,
1017    kind: EventKind,
1018    control: Control,
1019    session_map: Arc<Mutex<HashMap<RunId, Uuid>>>,
1020    store: Arc<dyn SessionDataStore>,
1021    handler: F,
1022) -> InvocationHandle
1023where
1024    F: Fn(RuntimeEventEnvelope, InvocationSession, Control) -> Fut + Send + Sync + 'static,
1025    Fut: Future<Output = ()> + Send + 'static,
1026{
1027    let handler = Arc::new(handler);
1028    let concurrency_limit = control.concurrency_limit();
1029    let semaphore = concurrency_limit.map(|limit| Arc::new(Semaphore::new(limit)));
1030    let task = tokio::spawn(async move {
1031        loop {
1032            match receiver.recv().await {
1033                Ok(event) => {
1034                    if control.is_cancelled() {
1035                        break;
1036                    }
1037                    if !kind.matches_agent(&event) {
1038                        continue;
1039                    }
1040                    let run_id = event.run_id();
1041                    let session_id = {
1042                        let mut map = lock_or_recover(&session_map);
1043                        if let AgentEvent::RunStarted(started) = &event {
1044                            map.insert(run_id, started.session_id);
1045                            Some(started.session_id)
1046                        } else {
1047                            map.get(&run_id).copied()
1048                        }
1049                    };
1050                    let envelope = RuntimeEventEnvelope {
1051                        event_id: super::stream::RuntimeEventId::new(),
1052                        seq: 0,
1053                        run_id,
1054                        session_id,
1055                        event,
1056                        emitted_at: chrono::Utc::now(),
1057                    };
1058                    let session = InvocationSession {
1059                        session_id,
1060                        run_id: Some(run_id),
1061                        store: Arc::clone(&store),
1062                    };
1063                    let permit = if let Some(sem) = semaphore.clone() {
1064                        match sem.acquire_owned().await {
1065                            Ok(permit) => Some(permit),
1066                            Err(_) => break,
1067                        }
1068                    } else {
1069                        None
1070                    };
1071                    let h = Arc::clone(&handler);
1072                    let c = control.clone();
1073                    tokio::spawn(async move {
1074                        let _permit = permit;
1075                        h(envelope, session, c).await;
1076                    });
1077                }
1078                Err(broadcast::error::RecvError::Closed) => break,
1079                Err(broadcast::error::RecvError::Lagged(_)) => {}
1080            }
1081        }
1082    });
1083    InvocationHandle { task }
1084}
1085
1086#[cfg(test)]
1087#[allow(clippy::unwrap_used, clippy::expect_used)]
1088mod tests {
1089    use super::*;
1090    use crate::event::{RunCompleted, RunStarted, TextDelta};
1091    use behest_provider::{ChatStreamEvent, FinishReason, ModelName, ProviderId, ToolChoice};
1092    use chrono::Utc;
1093    use serde_json::json;
1094    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1095    use std::time::Duration;
1096    use tokio::sync::{Notify, broadcast};
1097    use uuid::Uuid;
1098
1099    #[test]
1100    fn emit_request_converts_to_run_request() {
1101        let sid = Uuid::new_v4();
1102        let req = EmitRequest::new(ProviderId::new("p"), ModelName::new("m"), "hi")
1103            .with_session_id(sid)
1104            .with_client_request_id("cid")
1105            .with_metadata(json!({"k": "v"}));
1106        let run = req.into_run_request();
1107        assert_eq!(run.provider, ProviderId::new("p"));
1108        assert_eq!(run.model, ModelName::new("m"));
1109        assert_eq!(run.input, "hi");
1110        assert_eq!(run.session_id, Some(sid));
1111        assert_eq!(run.client_request_id.as_deref(), Some("cid"));
1112        assert_eq!(run.metadata, json!({"k": "v"}));
1113        assert!(matches!(run.tool_choice, ToolChoice::Auto));
1114        assert!(run.run_id.is_none());
1115    }
1116
1117    #[test]
1118    fn emit_request_default_metadata_is_null() {
1119        let req = EmitRequest::new(ProviderId::new("p"), ModelName::new("m"), "hi");
1120        assert!(req.metadata.is_null());
1121        let run = req.clone().into_run_request();
1122        assert!(run.metadata.is_null());
1123    }
1124
1125    #[test]
1126    fn event_kind_matches_agent_text_delta() {
1127        let ev = InvocationEvent::Agent(AgentEvent::TextDelta(TextDelta {
1128            run_id: RunId::new(),
1129            delta: "x".into(),
1130            timestamp: Utc::now(),
1131        }));
1132        assert!(EventKind::TextDelta.matches(&ev));
1133        assert!(EventKind::Any.matches(&ev));
1134        assert!(!EventKind::RunCompleted.matches(&ev));
1135        assert!(!EventKind::ChatTextDelta.matches(&ev));
1136    }
1137
1138    #[test]
1139    fn event_kind_matches_agent_run_completed() {
1140        let ev = InvocationEvent::Agent(AgentEvent::RunCompleted(RunCompleted {
1141            run_id: RunId::new(),
1142            finish_reason: FinishReason::Stop,
1143            iterations: 1,
1144            timestamp: Utc::now(),
1145        }));
1146        assert!(EventKind::RunCompleted.matches(&ev));
1147        assert!(EventKind::Any.matches(&ev));
1148        assert!(!EventKind::TextDelta.matches(&ev));
1149    }
1150
1151    #[test]
1152    fn event_kind_matches_chat_text_delta() {
1153        let ev = InvocationEvent::Chat(ChatStreamEvent::TextDelta { delta: "x".into() });
1154        assert!(EventKind::ChatTextDelta.matches(&ev));
1155        assert!(EventKind::Any.matches(&ev));
1156        assert!(!EventKind::TextDelta.matches(&ev));
1157    }
1158
1159    #[test]
1160    fn control_cancel_sets_flag_and_shares_state() {
1161        let c = Control::new();
1162        assert!(!c.is_cancelled());
1163        c.cancel();
1164        assert!(c.is_cancelled());
1165        let cloned = c.clone();
1166        assert!(cloned.is_cancelled(), "clone must share cancel state");
1167    }
1168
1169    #[test]
1170    fn control_set_data_and_data() {
1171        let c = Control::new();
1172        c.set_data(42_u32);
1173        c.set_data(String::from("hello"));
1174        assert_eq!(*c.data::<u32>().unwrap(), 42);
1175        assert_eq!(*c.data::<String>().unwrap(), "hello");
1176        assert!(c.data::<f64>().is_none());
1177    }
1178
1179    #[test]
1180    fn control_data_shared_across_clones() {
1181        let c = Control::new();
1182        c.set_data(99_u64);
1183        let cloned = c.clone();
1184        assert_eq!(*cloned.data::<u64>().unwrap(), 99);
1185    }
1186
1187    #[tokio::test]
1188    async fn memory_session_data_store_round_trip() {
1189        let store = MemorySessionDataStore::new();
1190        let sid = Uuid::new_v4();
1191        store.set(sid, "k".into(), json!({"x": 1})).await.unwrap();
1192        let val = store.get(sid, "k").await.unwrap();
1193        assert_eq!(val, Some(json!({"x": 1})));
1194        store.delete(sid, "k").await.unwrap();
1195        assert!(store.get(sid, "k").await.unwrap().is_none());
1196    }
1197
1198    #[tokio::test]
1199    async fn file_session_data_store_round_trip() {
1200        let dir = std::env::temp_dir().join(format!("behest_test_{}", Uuid::new_v4()));
1201        let store = FileSessionDataStore::new(&dir);
1202        let sid = Uuid::new_v4();
1203        store.set(sid, "name".into(), json!("alice")).await.unwrap();
1204        let val = store.get(sid, "name").await.unwrap();
1205        assert_eq!(val, Some(json!("alice")));
1206        store.delete(sid, "name").await.unwrap();
1207        assert!(store.get(sid, "name").await.unwrap().is_none());
1208        let _ = std::fs::remove_dir_all(&dir);
1209    }
1210
1211    #[tokio::test]
1212    async fn invocation_session_set_get_delete() {
1213        let session = InvocationSession {
1214            session_id: Some(Uuid::new_v4()),
1215            run_id: None,
1216            store: Arc::new(MemorySessionDataStore::new()),
1217        };
1218        session.set_data("key", json!(42)).await.unwrap();
1219        let val = session.get_data("key").await.unwrap();
1220        assert_eq!(val, Some(json!(42)));
1221        session.delete_data("key").await.unwrap();
1222        assert!(session.get_data("key").await.unwrap().is_none());
1223    }
1224
1225    #[tokio::test]
1226    async fn invocation_session_no_session_id_errors() {
1227        let session = InvocationSession {
1228            session_id: None,
1229            run_id: None,
1230            store: Arc::new(MemorySessionDataStore::new()),
1231        };
1232        let result = session.set_data("key", json!(1)).await;
1233        assert!(result.is_err());
1234    }
1235
1236    #[tokio::test]
1237    async fn on_only_handles_matching_events() {
1238        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
1239        let counter = Arc::new(AtomicUsize::new(0));
1240        let c = counter.clone();
1241        let handler = move |_, _, _| {
1242            let c = c.clone();
1243            async move {
1244                c.fetch_add(1, Ordering::SeqCst);
1245            }
1246        };
1247        let session_map = Arc::new(Mutex::new(HashMap::new()));
1248        let store: Arc<dyn SessionDataStore> = Arc::new(MemorySessionDataStore::new());
1249        let handle = spawn_listener(
1250            rx,
1251            EventKind::TextDelta,
1252            Control::new(),
1253            session_map,
1254            store,
1255            handler,
1256        );
1257
1258        let _ = tx.send(AgentEvent::TextDelta(TextDelta {
1259            run_id: RunId::new(),
1260            delta: "a".into(),
1261            timestamp: Utc::now(),
1262        }));
1263        let _ = tx.send(AgentEvent::RunStarted(RunStarted {
1264            run_id: RunId::new(),
1265            session_id: Uuid::new_v4(),
1266            provider: ProviderId::new("p"),
1267            model: ModelName::new("m"),
1268            timestamp: Utc::now(),
1269        }));
1270
1271        tokio::time::sleep(Duration::from_millis(100)).await;
1272        assert_eq!(
1273            counter.load(Ordering::SeqCst),
1274            1,
1275            "only the matching TextDelta event should be handled"
1276        );
1277        handle.abort();
1278    }
1279
1280    #[test]
1281    fn control_set_concurrency_limit_clamps_zero_to_one() {
1282        let control = Control::new();
1283
1284        control.set_concurrency_limit(0);
1285
1286        assert_eq!(control.concurrency_limit(), Some(1));
1287    }
1288
1289    #[tokio::test]
1290    async fn listener_applies_concurrency_backpressure_before_spawning_handlers() {
1291        let (tx, rx) = broadcast::channel::<AgentEvent>(1);
1292        let handled = Arc::new(AtomicUsize::new(0));
1293        let first_started = Arc::new(Notify::new());
1294        let release_first = Arc::new(Notify::new());
1295        let released = Arc::new(AtomicBool::new(false));
1296
1297        let h_handled = Arc::clone(&handled);
1298        let h_first_started = Arc::clone(&first_started);
1299        let h_release_first = Arc::clone(&release_first);
1300        let h_released = Arc::clone(&released);
1301        let handler = move |_, _, _| {
1302            let handled = Arc::clone(&h_handled);
1303            let first_started = Arc::clone(&h_first_started);
1304            let release_first = Arc::clone(&h_release_first);
1305            let released = Arc::clone(&h_released);
1306            async move {
1307                let current = handled.fetch_add(1, Ordering::SeqCst) + 1;
1308                if current == 1 {
1309                    first_started.notify_waiters();
1310                    release_first.notified().await;
1311                    released.store(true, Ordering::SeqCst);
1312                    return;
1313                }
1314
1315                if !released.load(Ordering::SeqCst) {
1316                    release_first.notified().await;
1317                }
1318            }
1319        };
1320        let session_map = Arc::new(Mutex::new(HashMap::new()));
1321        let store: Arc<dyn SessionDataStore> = Arc::new(MemorySessionDataStore::new());
1322        let control = Control::new();
1323        control.set_concurrency_limit(1);
1324        let handle = spawn_listener(
1325            rx,
1326            EventKind::TextDelta,
1327            control,
1328            session_map,
1329            store,
1330            handler,
1331        );
1332
1333        let _ = tx.send(AgentEvent::TextDelta(TextDelta {
1334            run_id: RunId::new(),
1335            delta: "first".into(),
1336            timestamp: Utc::now(),
1337        }));
1338        tokio::time::timeout(Duration::from_millis(100), first_started.notified())
1339            .await
1340            .expect("first handler should start");
1341
1342        for idx in 0..20 {
1343            let _ = tx.send(AgentEvent::TextDelta(TextDelta {
1344                run_id: RunId::new(),
1345                delta: format!("queued-{idx}"),
1346                timestamp: Utc::now(),
1347            }));
1348            tokio::task::yield_now().await;
1349        }
1350
1351        assert_eq!(handled.load(Ordering::SeqCst), 1);
1352        release_first.notify_waiters();
1353        tokio::time::sleep(Duration::from_millis(100)).await;
1354
1355        assert!(
1356            handled.load(Ordering::SeqCst) <= 3,
1357            "listener should not pre-spawn handlers while the limit is saturated"
1358        );
1359        handle.abort();
1360    }
1361
1362    #[tokio::test]
1363    async fn invocation_handle_abort_stops_listener() {
1364        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
1365        let counter = Arc::new(AtomicUsize::new(0));
1366        let c = counter.clone();
1367        let handler = move |_, _, _| {
1368            let c = c.clone();
1369            async move {
1370                c.fetch_add(1, Ordering::SeqCst);
1371            }
1372        };
1373        let session_map = Arc::new(Mutex::new(HashMap::new()));
1374        let store: Arc<dyn SessionDataStore> = Arc::new(MemorySessionDataStore::new());
1375        let handle = spawn_listener(
1376            rx,
1377            EventKind::Any,
1378            Control::new(),
1379            session_map,
1380            store,
1381            handler,
1382        );
1383
1384        let _ = tx.send(AgentEvent::RunStarted(RunStarted {
1385            run_id: RunId::new(),
1386            session_id: Uuid::new_v4(),
1387            provider: ProviderId::new("p"),
1388            model: ModelName::new("m"),
1389            timestamp: Utc::now(),
1390        }));
1391        tokio::time::sleep(Duration::from_millis(100)).await;
1392        let before = counter.load(Ordering::SeqCst);
1393        assert!(before >= 1, "first event should be handled");
1394
1395        handle.abort();
1396        tokio::time::sleep(Duration::from_millis(50)).await;
1397        assert!(handle.is_finished(), "listener should finish after abort");
1398
1399        let _ = tx.send(AgentEvent::RunStarted(RunStarted {
1400            run_id: RunId::new(),
1401            session_id: Uuid::new_v4(),
1402            provider: ProviderId::new("p"),
1403            model: ModelName::new("m"),
1404            timestamp: Utc::now(),
1405        }));
1406        tokio::time::sleep(Duration::from_millis(100)).await;
1407        assert_eq!(
1408            counter.load(Ordering::SeqCst),
1409            before,
1410            "no events should be handled after abort"
1411        );
1412    }
1413}