Skip to main content

agent_engine/session/
transport.rs

1//! `ClientTransport` + `LocalTransport` (PLAN-phase2 §2.7).
2//!
3//! `LocalTransport` is the in-process client: same `Runtime`, same
4//! `StreamEvent` values moved through a channel, never serialised. The
5//! socket flavour (`SocketTransport`, B2) implements the same trait.
6
7use std::sync::Arc;
8
9use tokio::sync::broadcast;
10
11use super::handle::SessionHandle;
12use super::types::{
13    AttachMode, AttachSnapshot, ClientId, ClientMeta, Envelope, SessionCommand, SessionEventWire,
14    SessionId, SessionMeta,
15};
16use super::view::RuntimeView;
17
18#[derive(Debug, thiserror::Error)]
19pub enum TransportError {
20    #[error("session closed")]
21    Closed,
22    #[error("backpressure")]
23    Backpressure,
24    #[error("io: {0}")]
25    Io(#[from] std::io::Error),
26    #[error("protocol: {0}")]
27    Protocol(String),
28    #[error("version: client {client} daemon {daemon}")]
29    Version { client: u32, daemon: u32 },
30    /// The operation does not exist on this transport (e.g. `reconnect` on
31    /// `LocalTransport`).
32    #[error("unsupported: {0}")]
33    Unsupported(&'static str),
34    /// The daemon announced a reload and closed; `reconnect` applies.
35    #[error("daemon reloading (generation {generation})")]
36    Reloading { generation: u64 },
37    /// The daemon/actor refused the attach or command (`AttachRefused`,
38    /// `DaemonFrame::Error` during attach).
39    #[error("refused: {0}")]
40    Refused(String),
41}
42
43#[async_trait::async_trait]
44pub trait ClientTransport: Send {
45    fn session_id(&self) -> &SessionId;
46    fn meta(&self) -> &SessionMeta;
47    /// Host-originated send (`from: None`; bypasses input ownership).
48    async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError>;
49    /// Send stamped with this client's id (the TUI uses this; ownership is
50    /// enforced by the actor, B1).
51    async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError>;
52    /// Next envelope, or `None` when the session ended / socket closed.
53    async fn next_event(&mut self) -> Option<Envelope>;
54    /// Sync, cheap, never stale by more than one `SettingChanged`: what the
55    /// TUI's getters read on day 2.
56    fn view(&self) -> Arc<RuntimeView>;
57    /// Client id assigned at Attach (needed for Detach/Resync).
58    fn client_id(&self) -> ClientId;
59    /// The mode this transport attached with.
60    fn mode(&self) -> AttachMode;
61    /// Current input owner — updated from `Attached` / `InputOwnerChanged`.
62    fn input_owner(&self) -> Option<ClientId>;
63    /// Socket only (Local returns `Err(Unsupported)`): after `Reloading`/EOF,
64    /// reconnect with backoff and re-attach with `mode` (C3/A4).
65    async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError>;
66    /// F9: single reconnect attempt (no retry loop). Socket only.
67    async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
68        self.reconnect(mode).await
69    }
70    /// `true` if the daemon announced a reload before EOF (graceful). Always
71    /// `false` on `LocalTransport`.
72    fn is_reload_pending(&self) -> bool {
73        false
74    }
75    /// The daemon pid from `Welcome`. Returns `0` on `LocalTransport`.
76    fn daemon_pid(&self) -> u32 {
77        0
78    }
79}
80
81/// Attach handshake budget.
82pub const ATTACH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
83/// Attach budget when the listed lifecycle is `Parked` (unpark ≤ 20 s, B3).
84pub const ATTACH_TIMEOUT_PARKED: std::time::Duration = std::time::Duration::from_secs(25);
85
86pub struct LocalTransport {
87    handle: SessionHandle,
88    rx: broadcast::Receiver<Envelope>,
89    client: ClientId,
90    meta: SessionMeta,
91    mode: AttachMode,
92    input_owner: Option<ClientId>,
93    /// Set after `Ended` (or actor gone): `next_event` returns `None` from then on.
94    ended: bool,
95}
96
97impl LocalTransport {
98    /// Attach in-process as a `Mirror` (= `attach_with(.., Mirror)`).
99    pub async fn attach(
100        handle: SessionHandle,
101        meta: ClientMeta,
102    ) -> Result<(Self, AttachSnapshot), TransportError> {
103        Self::attach_with(handle, meta, AttachMode::Mirror).await
104    }
105
106    /// Attach in-process: sends Attach, awaits the Attached envelope
107    /// (bounded 5 s), returns the transport + snapshot.
108    pub async fn attach_with(
109        handle: SessionHandle,
110        meta: ClientMeta,
111        mode: AttachMode,
112    ) -> Result<(Self, AttachSnapshot), TransportError> {
113        // Subscribe BEFORE sending so the Attached reply cannot be missed.
114        // `Attached` is broadcast; with one attach in flight per handle at a
115        // time (every host today) the first one seen is ours.
116        let mut rx = handle.subscribe();
117        handle
118            .send(SessionCommand::Attach {
119                client: meta,
120                mode,
121            })
122            .await?;
123
124        let wait = async {
125            loop {
126                match rx.recv().await {
127                    Ok(env) => match env.event {
128                        SessionEventWire::Attached { client, snapshot } => {
129                            return Ok((client, snapshot));
130                        }
131                        SessionEventWire::AttachRefused { message } => {
132                            return Err(TransportError::Refused(message));
133                        }
134                        _ => {}
135                    },
136                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
137                    Err(broadcast::error::RecvError::Closed) => {
138                        return Err(TransportError::Closed)
139                    }
140                }
141            }
142        };
143        let timeout = if handle.lifecycle() == super::types::SessionLifecycle::Parked {
144            ATTACH_TIMEOUT_PARKED
145        } else {
146            ATTACH_TIMEOUT
147        };
148        let (client, snapshot) = match tokio::time::timeout(timeout, wait).await {
149            Ok(r) => r?,
150            Err(_) => return Err(TransportError::Protocol("attach timed out".into())),
151        };
152        let meta = snapshot.meta.clone();
153        Ok((
154            Self {
155                handle,
156                rx,
157                client,
158                meta,
159                mode,
160                input_owner: snapshot.input_owner,
161                ended: false,
162            },
163            snapshot,
164        ))
165    }
166
167    pub fn handle(&self) -> &SessionHandle {
168        &self.handle
169    }
170}
171
172#[async_trait::async_trait]
173impl ClientTransport for LocalTransport {
174    fn session_id(&self) -> &SessionId {
175        &self.handle.id
176    }
177
178    fn meta(&self) -> &SessionMeta {
179        &self.meta
180    }
181
182    async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
183        self.handle.send(cmd).await
184    }
185
186    async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
187        self.handle.send_from(self.client, cmd).await
188    }
189
190    async fn next_event(&mut self) -> Option<Envelope> {
191        if self.ended {
192            return None;
193        }
194        // The handle itself holds a broadcast sender, so `Closed` never
195        // fires on its own: end-of-stream is `Ended` or the actor going away
196        // (drain what it left behind first).
197        let recv = tokio::select! {
198            biased;
199            r = self.rx.recv() => r,
200            _ = self.handle.closed() => match self.rx.try_recv() {
201                Ok(env) => Ok(env),
202                Err(broadcast::error::TryRecvError::Lagged(n)) => {
203                    Err(broadcast::error::RecvError::Lagged(n))
204                }
205                Err(_) => Err(broadcast::error::RecvError::Closed),
206            },
207        };
208        match recv {
209            Ok(env) => {
210                match &env.event {
211                    SessionEventWire::Ended { .. } => self.ended = true,
212                    SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
213                    _ => {}
214                }
215                Some(env)
216            }
217            Err(broadcast::error::RecvError::Lagged(n)) => {
218                // server.rs Lagged pattern: warn, tell the client once, keep going.
219                tracing::warn!(session = %self.handle.id, dropped = n, "session event stream lagged");
220                Some(Envelope {
221                    session_id: self.handle.id.clone(),
222                    seq: u64::MAX,
223                    ts: chrono::Utc::now(),
224                    event: SessionEventWire::SystemNotice(format!(
225                        "event stream lagged; {n} dropped"
226                    )),
227                })
228            }
229            Err(broadcast::error::RecvError::Closed) => {
230                self.ended = true;
231                None
232            }
233        }
234    }
235
236    fn view(&self) -> Arc<RuntimeView> {
237        Arc::clone(&self.handle.view())
238    }
239
240    fn client_id(&self) -> ClientId {
241        self.client
242    }
243
244    fn mode(&self) -> AttachMode {
245        self.mode
246    }
247
248    fn input_owner(&self) -> Option<ClientId> {
249        self.input_owner
250    }
251
252    async fn reconnect(&mut self, _mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
253        Err(TransportError::Unsupported("reconnect: in-process transport"))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::session::types::*;
261    use crate::{LlmEvent, SessionEvent, StreamEvent};
262
263    async fn next(t: &mut LocalTransport) -> Envelope {
264        tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
265            .await
266            .expect("timely")
267            .expect("open")
268    }
269
270    /// Debug strings of every `Stream(_)` up to and including the next `Conversation`.
271    async fn collect(t: &mut LocalTransport) -> Vec<String> {
272        let mut out = Vec::new();
273        loop {
274            let env = next(t).await;
275            let done = matches!(env.event, SessionEventWire::Conversation(_));
276            if matches!(env.event, SessionEventWire::Stream(_)) {
277                out.push(format!("{:?}", env.event));
278            }
279            if done {
280                break;
281            }
282        }
283        out
284    }
285
286    #[tokio::test]
287    async fn local_transport_round_trip_over_echo() {
288        let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-1"));
289        let (mut t, snap) = LocalTransport::attach(handle, ClientMeta::new(ClientKind::Test))
290            .await
291            .unwrap();
292        assert_eq!(t.session_id().as_str(), "lt-1");
293        assert_eq!(t.client_id(), ClientId(1));
294        assert_eq!(t.mode(), AttachMode::Mirror);
295        assert!(t.input_owner().is_none());
296        assert!(matches!(t.reconnect(AttachMode::Mirror).await, Err(TransportError::Unsupported(_))));
297        assert_eq!(snap.meta.model, "echo");
298        assert_eq!(t.view().model, "echo");
299        assert!(snap.conversation.api_messages.is_empty());
300
301        // ClientJoined follows Attached on the shared channel.
302        let joined = next(&mut t).await;
303        assert!(matches!(
304            joined.event,
305            SessionEventWire::ClientJoined {
306                client: ClientId(1),
307                kind: ClientKind::Test
308            }
309        ));
310
311        t.send(SessionCommand::Submit {
312            text: "ping".into(),
313            attachments: vec![],
314        })
315        .await
316        .unwrap();
317
318        let mut seen = Vec::new();
319        loop {
320            let env = next(&mut t).await;
321            let done = matches!(env.event, SessionEventWire::Conversation(_));
322            seen.push(env);
323            if done {
324                break;
325            }
326        }
327        assert!(matches!(seen[0].event, SessionEventWire::TurnStarted { .. }));
328        match &seen[1].event {
329            SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "ping"),
330            other => panic!("unexpected {other:?}"),
331        }
332        assert!(matches!(
333            seen[3].event,
334            SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
335        ));
336        let seqs: Vec<u64> = seen.iter().map(|e| e.seq).collect();
337        assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1), "gapless: {seqs:?}");
338
339        // Query{View} answered with the published view.
340        t.send(SessionCommand::Query {
341            id: 7,
342            query: SessionQuery::View,
343        })
344        .await
345        .unwrap();
346        let q = next(&mut t).await;
347        match q.event {
348            SessionEventWire::QueryResult { id, value } => {
349                assert_eq!(id, 7);
350                assert_eq!(value["model"], "echo");
351            }
352            other => panic!("unexpected {other:?}"),
353        }
354
355        // Detach does not end the session; End does.
356        t.send(SessionCommand::Detach { client: t.client_id() })
357            .await
358            .unwrap();
359        let left = next(&mut t).await;
360        assert!(matches!(left.event, SessionEventWire::ClientLeft { client: ClientId(1) }));
361        assert!(t.handle().is_alive());
362
363        t.send(SessionCommand::End {
364            reason: EndReason::HostShutdown,
365        })
366        .await
367        .unwrap();
368        let ended = next(&mut t).await;
369        assert!(matches!(ended.event, SessionEventWire::Ended { .. }));
370        task.await.unwrap();
371        assert!(t.next_event().await.is_none());
372        assert!(matches!(
373            t.send(SessionCommand::Save).await,
374            Err(TransportError::Closed)
375        ));
376    }
377
378    #[tokio::test]
379    async fn two_local_clients_see_the_same_stream() {
380        let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-2"));
381        let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
382            .await
383            .unwrap();
384        let (mut b, snap_b) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Attach))
385            .await
386            .unwrap();
387        assert_ne!(a.client_id(), b.client_id());
388        assert_eq!(snap_b.clients.len(), 2);
389
390        a.send(SessionCommand::Submit {
391            text: "x".into(),
392            attachments: vec![],
393        })
394        .await
395        .unwrap();
396
397        let sa = collect(&mut a).await;
398        let sb = collect(&mut b).await;
399        assert_eq!(sa, sb);
400        assert_eq!(sa.len(), 3);
401
402        drop((a, b, handle));
403        task.await.unwrap();
404    }
405}