oxios_kernel/streaming_sink.rs
1//! Streaming sink registry — per-session lookup table for the WebSocket
2//! streaming path.
3//!
4//! Why this exists
5//! ================
6//!
7//! The agent runtime's `AgentEvent::TextChunk` callback fires per text delta
8//! during `run_streaming`. The Web UI needs each delta as a live `token`
9//! chunk. Without plumbing `streaming_sink: Option<...>` through every layer
10//! between the gateway and the runtime callback (Supervisor trait, AgentApi,
11//! AgentTool, AgentLifecycleManager, Orchestrator, AgentRuntime), we use a
12//! session-keyed registry:
13//!
14//! 1. The **gateway** creates `mpsc::unbounded()` + a collector task right
15//! before invoking the orchestrator. The collector holds the **strong**
16//! `Arc<UnboundedSender<StreamDelta>>` for the duration of the turn
17//! (with `target_conn_id = Some(conn_id)` on every partial `OutgoingMessage`,
18//! mirroring `gateway.rs:491`). The gateway also stores a `Weak` under
19//! `session_id` in this registry.
20//! 2. The **runtime** callback looks up the registry by `session_id` (which it
21//! already has via `transparency_session`) and sends `StreamDelta::Text`
22//! directly into the channel — no plumbing through intermediate layers.
23//! 3. When the collector task completes, the strong `Arc` drops. `Weak::upgrade`
24//! on the next runtime lookup returns `None` and the registry lookup
25//! cleanly misses — **no explicit unregister required, no stale entries**.
26//!
27//! Design rationale (vs threaded `Option<StreamingSinkTx>` params)
28//! =================================================================
29//!
30//! `run_with_directive` is a `Supervisor` trait method with concrete impls
31//! on `BasicSupervisor` and `NoOpSupervisor`, and `dyn Supervisor` is held
32//! by `AgentLifecycleManager`, `AgentApi`, and `AgentTool`. Threading the
33//! sink through there would touch every impl + every holder. The registry
34//! avoids all of that because the runtime callback already has the
35//! `session_id` it needs to look up the sink — no new arguments required.
36//!
37//! Concurrency
38//! ===========
39//!
40//! The registry is `Send + Sync` (it owns a `Mutex<HashMap<...>>` of `Weak`s,
41//! neither contains interior mutability beyond the mutex itself). Lookup is
42//! O(n) where n = active turns; in practice n ≤ a few during normal use, so
43//! a `HashMap` is fine. A `DashMap` would be a one-line swap if load grows.
44
45use std::collections::HashMap;
46use std::sync::{Arc, Mutex, Weak};
47
48use tokio::sync::mpsc::UnboundedSender;
49
50use crate::agent_runtime::StreamDelta;
51
52/// Strong sender side, wrapped in `Arc` so the runtime callback can clone
53/// cheaply on every lookup. Re-exported here so callers don't have to know
54/// the runtime's internal type.
55pub type StreamingSinkSender = Arc<UnboundedSender<StreamDelta>>;
56
57/// Per-session lookup table. Shared via `Arc` between the kernel handle,
58/// the agent runtime, and the gateway dispatch layer.
59#[derive(Default)]
60pub struct StreamingSinkRegistry {
61 inner: Mutex<HashMap<String, Weak<UnboundedSender<StreamDelta>>>>,
62}
63
64impl StreamingSinkRegistry {
65 /// Create an empty registry.
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 /// Register a strong sender under `session_id`. Stores a `Weak` so the
71 /// entry auto-cleans when the gateway's collector drops its strong
72 /// reference — no explicit `unregister` needed in the happy path.
73 pub fn register(&self, session_id: &str, sender: &StreamingSinkSender) {
74 let weak = Arc::downgrade(sender);
75 self.inner
76 .lock()
77 .expect("StreamingSinkRegistry mutex poisoned")
78 .insert(session_id.to_string(), weak);
79 }
80
81 /// Look up the sink for `session_id`. Returns `Some(tx)` only if a
82 /// strong sender is still alive (i.e., the collector task hasn't
83 /// completed and dropped its `Arc`). Misses are silent — the runtime
84 /// simply skips emitting the delta.
85 pub fn lookup(&self, session_id: &str) -> Option<StreamingSinkSender> {
86 let guard = self
87 .inner
88 .lock()
89 .expect("StreamingSinkRegistry mutex poisoned");
90 guard
91 .get(session_id)
92 .and_then(|w| w.upgrade())
93 .map(|a| Arc::clone(&a))
94 }
95
96 /// Explicit unregister. Rarely needed — `Weak::upgrade` returning
97 /// `None` is the normal cleanup path. Exposed for symmetry / tests
98 /// that want to assert pre-cleanup behavior.
99 pub fn unregister(&self, session_id: &str) {
100 self.inner
101 .lock()
102 .expect("StreamingSinkRegistry mutex poisoned")
103 .remove(session_id);
104 }
105
106 /// Test/observability helper: number of active sessions.
107 #[cfg(test)]
108 pub fn len(&self) -> usize {
109 self.inner
110 .lock()
111 .expect("StreamingSinkRegistry mutex poisoned")
112 .len()
113 }
114 /// Returns true if there are no active streaming sessions.
115 #[cfg(test)]
116 pub fn is_empty(&self) -> bool {
117 self.len() == 0
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::agent_runtime::StreamingSinkTx;
125
126 #[test]
127 fn lookup_miss_when_empty() {
128 let r = StreamingSinkRegistry::new();
129 assert!(r.lookup("missing").is_none());
130 }
131
132 #[test]
133 fn lookup_returns_strong_when_alive() {
134 let r = StreamingSinkRegistry::new();
135 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<StreamDelta>();
136 let sender: StreamingSinkTx = Arc::new(tx);
137 r.register("s1", &sender);
138 let looked = r.lookup("s1").expect("should find live sink");
139 assert!(Arc::ptr_eq(&looked, &sender));
140 }
141
142 #[test]
143 fn lookup_misses_after_drop() {
144 let r = StreamingSinkRegistry::new();
145 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<StreamDelta>();
146 let sender: StreamingSinkTx = Arc::new(tx);
147 r.register("s1", &sender);
148 drop(sender);
149 assert!(r.lookup("s1").is_none(), "stale Weak must not upgrade");
150 }
151
152 #[test]
153 fn unregister_clears_entry() {
154 let r = StreamingSinkRegistry::new();
155 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<StreamDelta>();
156 let sender: StreamingSinkTx = Arc::new(tx);
157 r.register("s1", &sender);
158 r.unregister("s1");
159 assert!(r.lookup("s1").is_none());
160 // The strong sender is still alive — caller can keep using it
161 // directly; the registry just no longer points at it.
162 assert!(Arc::strong_count(&sender) >= 1);
163 }
164}