Skip to main content

agent_engine/session/
socket_transport.rs

1//! `SocketTransport` — the client side of the daemon UDS (PLAN-phase2 §2.10).
2//!
3//! Line-JSON frames (`wire.rs`). A writer task owns the write half; the
4//! reader lives on the transport. Backpressure mirrors `LocalTransport`:
5//! `send` fails with `Backpressure` when the bounded writer queue is full and
6//! `Closed` once the socket is gone — never a silent drop.
7//!
8//! Tracing here logs frame *types* only (`ClientFrame`'s redacting `Debug`).
9
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
16use tokio::net::UnixStream;
17use tokio::sync::mpsc;
18
19use super::handle::CMD_CHAN_CAP;
20use super::transport::{ClientTransport, TransportError, ATTACH_TIMEOUT, ATTACH_TIMEOUT_PARKED};
21use super::types::*;
22use super::view::RuntimeView;
23use super::wire::*;
24
25pub const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
26pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
27/// How long `close` waits for the daemon's answer: its queue retry, the
28/// actor's reply and the session's end (`daemon/close.rs` budgets), plus a
29/// margin.
30pub const CLOSE_TIMEOUT: Duration = Duration::from_secs(
31    crate::daemon::close::CLOSE_SEND_RETRY.as_secs()
32        + crate::daemon::close::CLOSE_REPLY_BUDGET.as_secs()
33        + crate::daemon::close::CLOSE_END_BUDGET.as_secs()
34        + 2,
35);
36/// How long `pin` waits for the daemon's answer: its longest wait (a parked
37/// session unparking) plus a margin.
38pub const PIN_TIMEOUT: Duration =
39    Duration::from_secs(crate::daemon::pin::PIN_UNPARK_BUDGET.as_secs() + 2);
40/// How long `reload_extension` waits: the daemon first waits for the
41/// extension manager's lock, then stops the old process and runs the new
42/// one's start-up handshake (each bounded by the extension runtime's own
43/// timeouts, around 15 s together). Generous, because giving up early only
44/// hides the answer: the daemon finishes the reload either way.
45pub const RELOAD_EXTENSION_TIMEOUT: Duration = Duration::from_secs(60);
46
47/// A connection past `Hello`/`Welcome`, before `Attach`.
48pub struct Connected {
49    reader: BufReader<OwnedReadHalf>,
50    writer: mpsc::Sender<ClientFrame>,
51    writer_task: tokio::task::JoinHandle<()>,
52    pub welcome: Welcome,
53    /// Remembered for `reconnect`.
54    path: PathBuf,
55    hello: Hello,
56    buf: FrameBuf,
57}
58
59pub struct Pong {
60    pub pid: u32,
61    pub uptime_s: u64,
62    pub sessions: usize,
63}
64
65/// What `synaps auth state` got from the daemon.
66#[derive(Debug)]
67pub enum AuthStateReply {
68    Report(agent_core::auth::AuthStateReport),
69    /// The daemon predates `AuthState`: it answered the frame the way an
70    /// old daemon answers any frame it cannot decode. Its version and build
71    /// (empty when the daemon does not report one), from its `Welcome`.
72    DaemonPredates { daemon_version: String, daemon_build: String },
73}
74
75/// An old daemon's reply to `AuthState` (daemon/conn.rs `Read::Bad`: serde's
76/// unknown-variant error behind "malformed frame: "), and nothing else.
77pub fn predates_auth_state(message: &str) -> bool {
78    message.starts_with("malformed frame: unknown variant `auth_state`")
79}
80
81/// Reused line buffer for `read_frame`: a big `Attached`/`MessageHistory`
82/// frame grows it once; after any frame > 1 MiB it shrinks back to 64 KiB so
83/// the peak is not retained for the life of the connection (§2.1 #1).
84#[derive(Default)]
85pub struct FrameBuf {
86    line: Vec<u8>,
87    /// Length of the last decoded frame (bytes, without the newline).
88    last_frame_bytes: usize,
89}
90
91const FRAME_BUF_KEEP: usize = 64 * 1024;
92const FRAME_BUF_SHRINK_ABOVE: usize = 1024 * 1024;
93
94impl FrameBuf {
95    fn after_frame(&mut self, n: usize) {
96        self.last_frame_bytes = n;
97        if self.line.capacity() > FRAME_BUF_SHRINK_ABOVE {
98            self.line = Vec::new();
99            self.line.reserve(FRAME_BUF_KEEP);
100        }
101    }
102}
103
104async fn read_frame(reader: &mut BufReader<OwnedReadHalf>, buf: &mut FrameBuf) -> Result<Option<DaemonFrame>, TransportError> {
105    // Cancel-safe: the TUI polls this inside `select!`, so a partial line
106    // stays in `buf.line` across a dropped future and is cleared only once
107    // a whole frame has been taken out of it.
108    loop {
109        // `take` bounds the read so an oversize frame cannot grow memory.
110        let limit = (MAX_FRAME_BYTES as u64 + 1).saturating_sub(buf.line.len() as u64);
111        let n = tokio::io::AsyncReadExt::take(&mut *reader, limit)
112            .read_until(b'\n', &mut buf.line)
113            .await?;
114        if n == 0 {
115            return Ok(None);
116        }
117        if buf.line.len() > MAX_FRAME_BYTES {
118            buf.line.clear();
119            return Err(TransportError::Protocol(frame_limit_msg()));
120        }
121        let decoded = {
122            let t = std::str::from_utf8(&buf.line)
123                .map_err(|e| TransportError::Protocol(format!("frame is not utf-8: {e}")))
124                .map(str::trim_end);
125            match t {
126                Ok("") => None,
127                Ok(t) => Some((decode_line::<DaemonFrame>(t).map(Some).map_err(TransportError::Protocol), t.len())),
128                Err(e) => Some((Err(e), 0)),
129            }
130        };
131        buf.line.clear();
132        let Some((decoded, len)) = decoded else { continue };
133        buf.after_frame(len);
134        return decoded;
135    }
136}
137
138fn spawn_writer(mut w: OwnedWriteHalf) -> (mpsc::Sender<ClientFrame>, tokio::task::JoinHandle<()>) {
139    let (tx, mut rx) = mpsc::channel::<ClientFrame>(CMD_CHAN_CAP);
140    let task = tokio::spawn(async move {
141        while let Some(frame) = rx.recv().await {
142            let line = match encode_line(&frame) {
143                Ok(l) => l,
144                Err(e) => {
145                    tracing::warn!(frame = ?frame, "socket_transport: encode failed: {e}");
146                    continue;
147                }
148            };
149            if let Err(e) = w.write_all(line.as_bytes()).await {
150                tracing::debug!("socket_transport: write failed: {e}");
151                break;
152            }
153            if matches!(frame, ClientFrame::Bye) {
154                let _ = w.shutdown().await;
155                break;
156            }
157        }
158    });
159    (tx, task)
160}
161
162impl Connected {
163    /// Hello/Welcome only. `Refused{Version}` → `TransportError::Version`;
164    /// any other refusal → `Protocol`.
165    pub async fn connect(path: &Path, hello: Hello) -> Result<Self, TransportError> {
166        let stream = tokio::time::timeout(CONNECT_TIMEOUT, UnixStream::connect(path))
167            .await
168            .map_err(|_| TransportError::Protocol("connect timed out".into()))??;
169        let (r, w) = stream.into_split();
170        let mut reader = BufReader::new(r);
171        let (writer, writer_task) = spawn_writer(w);
172        let client_version = hello.protocol_version;
173        let hello_copy = hello.clone();
174        let mut buf = FrameBuf::default();
175        writer
176            .send(ClientFrame::Hello(hello))
177            .await
178            .map_err(|_| TransportError::Closed)?;
179        let frame = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut reader, &mut buf))
180            .await
181            .map_err(|_| TransportError::Protocol("handshake timed out".into()))??;
182        match frame {
183            Some(DaemonFrame::Welcome(welcome)) => {
184                if welcome.protocol_version != client_version {
185                    return Err(TransportError::Version { client: client_version, daemon: welcome.protocol_version });
186                }
187                Ok(Self { reader, writer, writer_task, welcome, path: path.to_path_buf(), hello: hello_copy, buf })
188            }
189            Some(DaemonFrame::Refused { reason: RefuseReason::Version { daemon_version, .. }, .. }) => {
190                Err(TransportError::Version { client: client_version, daemon: daemon_version })
191            }
192            Some(DaemonFrame::Refused { reason, message }) => {
193                Err(TransportError::Protocol(format!("refused ({reason:?}): {message}")))
194            }
195            Some(other) => Err(TransportError::Protocol(format!("expected welcome, got {other:?}"))),
196            None => Err(TransportError::Closed),
197        }
198    }
199
200    async fn request(&mut self, frame: ClientFrame) -> Result<DaemonFrame, TransportError> {
201        self.writer.send(frame).await.map_err(|_| TransportError::Closed)?;
202        tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
203            .await
204            .map_err(|_| TransportError::Protocol("reply timed out".into()))??
205            .ok_or(TransportError::Closed)
206    }
207
208    pub async fn ping(&mut self) -> Result<Pong, TransportError> {
209        match self.request(ClientFrame::Ping).await? {
210            DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
211            other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
212        }
213    }
214
215    pub async fn sessions(&mut self) -> Result<Vec<SessionMeta>, TransportError> {
216        match self.request(ClientFrame::Sessions).await? {
217            DaemonFrame::SessionList { sessions } => Ok(sessions),
218            other => Err(TransportError::Protocol(format!("expected session list, got {other:?}"))),
219        }
220    }
221
222    /// `Purge` → the daemon runs `memstat::purge_arenas()`; replies `Pong`.
223    pub async fn purge(&mut self) -> Result<Pong, TransportError> {
224        match self.request(ClientFrame::Purge).await? {
225            DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
226            DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
227            other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
228        }
229    }
230
231    /// `AuthState` → the daemon broker's in-memory account state. A daemon
232    /// that predates the frame is [`AuthStateReply::DaemonPredates`]; any
233    /// other error is returned as itself.
234    pub async fn auth_state(&mut self, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
235        match self.request(ClientFrame::AuthState(query)).await? {
236            DaemonFrame::AuthState { report } => Ok(AuthStateReply::Report(report)),
237            DaemonFrame::Error { session_id: None, message } if predates_auth_state(&message) => {
238                Ok(AuthStateReply::DaemonPredates {
239                    daemon_version: self.welcome.daemon_version.clone(),
240                    daemon_build: self.welcome.daemon_build.clone(),
241                })
242            }
243            DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
244            other => Err(TransportError::Protocol(format!("expected auth state, got {other:?}"))),
245        }
246    }
247
248    pub async fn shutdown(&mut self, force: bool) -> Result<(), TransportError> {
249        match self.request(ClientFrame::Shutdown { force }).await? {
250            DaemonFrame::Bye { .. } => Ok(()),
251            DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
252            other => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
253        }
254    }
255
256    /// `Close{id, force, attached}`: end one session with host authority.
257    /// `Ok(text)` = the session ended (the actor's `/close` reply, with how
258    /// to continue it). A refusal (work in flight, clients attached without
259    /// `attached`, daemon reloading) → `Refused(text)`;
260    /// no such session → `UnknownSession`; a daemon that predates `Close`
261    /// → `DaemonTooOld`. Waits up to [`CLOSE_TIMEOUT`]: the session saves
262    /// and runs its end hooks before the daemon answers.
263    pub async fn close(&mut self, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
264        self.writer
265            .send(ClientFrame::Close { session_id: id.clone(), force, attached })
266            .await
267            .map_err(|_| TransportError::Closed)?;
268        let frame = tokio::time::timeout(CLOSE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
269            .await
270            .map_err(|_| TransportError::Protocol("close reply timed out".into()))??;
271        close_reply(frame, &self.welcome.daemon_version)
272    }
273
274    /// `Pin{id, on}`: pin or unpin one session with host authority.
275    /// `Ok(on)` = the session's pin is now `on`. A refusal (daemon
276    /// reloading, session live in another process) → `Refused(text)`; no
277    /// such session → `UnknownSession`; a daemon that predates `Pin` →
278    /// `DaemonTooOld`.
279    pub async fn pin(&mut self, id: &SessionId, on: bool) -> Result<bool, TransportError> {
280        self.writer
281            .send(ClientFrame::Pin { session_id: id.clone(), on })
282            .await
283            .map_err(|_| TransportError::Closed)?;
284        let frame = tokio::time::timeout(PIN_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
285            .await
286            .map_err(|_| TransportError::Protocol("pin reply timed out".into()))??;
287        pin_reply(frame, &self.welcome.daemon_version)
288    }
289
290    /// `ReloadExtension{extension}`: stop that extension and start it again
291    /// from what is installed now. `Ok(Ok(report))` = reloaded;
292    /// `Ok(Err(failure))` = the daemon tried and says why not; a refusal
293    /// (daemon reloading) → `Refused(text)`; a daemon that predates the
294    /// frame → `DaemonTooOld`.
295    pub async fn reload_extension(
296        &mut self,
297        extension: &str,
298    ) -> Result<Result<ExtensionReloadReport, ExtensionReloadFailure>, TransportError> {
299        self.writer
300            .send(ClientFrame::ReloadExtension { extension: extension.to_string() })
301            .await
302            .map_err(|_| TransportError::Closed)?;
303        let frame = tokio::time::timeout(RELOAD_EXTENSION_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
304            .await
305            .map_err(|_| TransportError::Protocol("extension reload reply timed out".into()))??;
306        reload_extension_reply(frame, &self.welcome.daemon_version)
307    }
308
309    /// Say goodbye and drop the connection.
310    pub async fn bye(self) {
311        let _ = self.writer.send(ClientFrame::Bye).await;
312        let _ = tokio::time::timeout(Duration::from_millis(200), self.writer_task).await;
313    }
314}
315
316/// The daemon's answer to `Close`, as [`Connected::close`] reports it.
317fn close_reply(frame: Option<DaemonFrame>, daemon_version: &str) -> Result<String, TransportError> {
318    match frame {
319        Some(DaemonFrame::Closed { text, .. }) => Ok(text),
320        Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
321            Err(TransportError::UnknownSession(message))
322        }
323        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
324        // An older daemon cannot decode the frame; it answers with serde's
325        // "unknown variant `close`" and keeps the connection open.
326        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `close`") => {
327            Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon close" })
328        }
329        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
330        Some(other) => Err(TransportError::Protocol(format!("expected closed, got {other:?}"))),
331        None => Err(TransportError::Closed),
332    }
333}
334
335/// The daemon's answer to `Pin`, as [`Connected::pin`] reports it.
336fn pin_reply(frame: Option<DaemonFrame>, daemon_version: &str) -> Result<bool, TransportError> {
337    match frame {
338        Some(DaemonFrame::Pinned { on, .. }) => Ok(on),
339        Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
340            Err(TransportError::UnknownSession(message))
341        }
342        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
343        // An older daemon cannot decode the frame; it answers with serde's
344        // "unknown variant `pin`" and keeps the connection open.
345        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `pin`") => {
346            Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon pin" })
347        }
348        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
349        Some(other) => Err(TransportError::Protocol(format!("expected pinned, got {other:?}"))),
350        None => Err(TransportError::Closed),
351    }
352}
353
354pub use crate::extensions::manager::ExtensionReloadReport;
355
356/// The daemon's reason an extension was not reloaded
357/// (`DaemonFrame::ExtensionReloadFailed`).
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct ExtensionReloadFailure {
360    pub message: String,
361    /// The old instance was stopped before the new one failed to start: the
362    /// extension is not loaded now. `false` = the running one (if any) was
363    /// not touched.
364    pub unloaded: bool,
365}
366
367/// The daemon's answer to `ReloadExtension`, as
368/// [`Connected::reload_extension`] reports it.
369fn reload_extension_reply(
370    frame: Option<DaemonFrame>,
371    daemon_version: &str,
372) -> Result<Result<ExtensionReloadReport, ExtensionReloadFailure>, TransportError> {
373    match frame {
374        Some(DaemonFrame::ExtensionReloaded { report }) => Ok(Ok(report)),
375        Some(DaemonFrame::ExtensionReloadFailed { message, unloaded, .. }) => {
376            Ok(Err(ExtensionReloadFailure { message, unloaded }))
377        }
378        Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
379        // An older daemon cannot decode the frame; it answers with serde's
380        // "unknown variant `reload_extension`" and keeps the connection open.
381        Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `reload_extension`") => {
382            Err(TransportError::DaemonTooOld {
383                daemon_version: daemon_version.to_string(),
384                request: "daemon reload-extension",
385            })
386        }
387        Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
388        Some(other) => Err(TransportError::Protocol(format!("expected extension_reloaded, got {other:?}"))),
389        None => Err(TransportError::Closed),
390    }
391}
392
393pub struct SocketTransport {
394    reader: BufReader<OwnedReadHalf>,
395    writer: mpsc::Sender<ClientFrame>,
396    _writer_task: tokio::task::JoinHandle<()>,
397    session: SessionId,
398    meta: SessionMeta,
399    client: ClientId,
400    view: Arc<arc_swap::ArcSwap<RuntimeView>>,
401    pub welcome: Welcome,
402    mode: AttachMode,
403    input_owner: Option<ClientId>,
404    roster: Roster,
405    client_input: ClientInput,
406    ended: bool,
407    path: PathBuf,
408    hello: Hello,
409    /// `Reloading` seen; the next EOF is a reload, not a death.
410    reload_pending: Option<u64>,
411    /// Why `next_event` returned `None` (`Reloading{generation}` after a
412    /// reload announcement) — the TUI branches to `reconnect` on it.
413    last_error: Option<TransportError>,
414    /// Local `api_messages` mirror (`HistoryMode::Full` only): seeded by
415    /// `Attached`, updated by `Stream(MessageHistory)` and
416    /// `QueryResult{DIGEST_RESYNC_QUERY_ID}`. `Conversation` arrives as a
417    /// digest; the mirror fills it in. Empty forever in `Digest` mode.
418    messages: Vec<crate::SharedMessage>,
419    /// Digest awaiting a `Messages` re-query (hash miss; Full mode only).
420    pending_digest: Option<ConversationDigest>,
421    /// What we asked for in `Hello` (phase 4 §2.3).
422    history: HistoryMode,
423    buf: FrameBuf,
424}
425
426impl SocketTransport {
427    /// Hello/Welcome only (50 ms connect + 2 s handshake budgets).
428    pub async fn connect(path: &Path, hello: Hello) -> Result<Connected, TransportError> {
429        Connected::connect(path, hello).await
430    }
431
432    /// Attach (existing or create) on a handshaken connection. `AttachRefused`
433    /// and `DaemonFrame::Error` alike → `TransportError::Refused`. Waits
434    /// `ATTACH_TIMEOUT_PARKED` when the daemon lists the session as `Parked`.
435    pub async fn attach(mut conn: Connected, attach: Attach) -> Result<(Self, AttachSnapshot), TransportError> {
436        let (mode, parked) = match &attach {
437            Attach::Existing { session_id, mode } => (
438                *mode,
439                conn.welcome
440                    .sessions
441                    .iter()
442                    .any(|m| &m.id == session_id && m.lifecycle == SessionLifecycle::Parked),
443            ),
444            Attach::Create { mode, .. } => (*mode, false),
445        };
446        conn.writer
447            .send(ClientFrame::Attach(attach))
448            .await
449            .map_err(|_| TransportError::Closed)?;
450        let budget = if parked { ATTACH_TIMEOUT_PARKED } else { ATTACH_TIMEOUT };
451        let deadline = tokio::time::Instant::now() + budget;
452        let attached = loop {
453            let frame = tokio::time::timeout_at(deadline, read_frame(&mut conn.reader, &mut conn.buf))
454                .await
455                .map_err(|_| TransportError::Protocol("attach timed out".into()))??;
456            match frame {
457                Some(DaemonFrame::Attached(a)) => break a,
458                Some(DaemonFrame::Error { message, .. }) => return Err(TransportError::Refused(message)),
459                Some(DaemonFrame::Event(w)) => match w.event {
460                    WireSessionEvent::AttachRefused { message } => return Err(TransportError::Refused(message)),
461                    // Notices/lifecycle chatter may precede `Attached` (unpark).
462                    _ => continue,
463                },
464                Some(DaemonFrame::Bye { .. }) | None => return Err(TransportError::Closed),
465                Some(other) => return Err(TransportError::Protocol(format!("expected attached, got {other:?}"))),
466            }
467        };
468        let (client, snapshot) = attached.into_snapshot();
469        let meta = snapshot.meta.clone();
470        let view = Arc::new(arc_swap::ArcSwap::from_pointee(snapshot.view.clone()));
471        let history = conn.hello.client.history;
472        let messages = match history {
473            HistoryMode::Full => snapshot.conversation.api_messages.clone(),
474            HistoryMode::Digest => Vec::new(),
475        };
476        Ok((
477            Self {
478                reader: conn.reader,
479                writer: conn.writer,
480                _writer_task: conn.writer_task,
481                session: meta.id.clone(),
482                meta,
483                client,
484                view,
485                welcome: conn.welcome,
486                mode,
487                input_owner: snapshot.input_owner,
488                roster: Roster::from_snapshot(&snapshot),
489                client_input: snapshot.client_input,
490                ended: false,
491                path: conn.path,
492                hello: conn.hello,
493                reload_pending: None,
494                last_error: None,
495                messages,
496                pending_digest: None,
497                history,
498                buf: conn.buf,
499            },
500            snapshot,
501        ))
502    }
503
504    /// Why the last `next_event` returned `None` (if it was not a plain end).
505    pub fn last_error(&self) -> Option<&TransportError> {
506        self.last_error.as_ref()
507    }
508
509    /// `true` if the daemon announced a reload before EOF (graceful); `false`
510    /// means a crash/SIGKILL (unexpected).
511    pub fn is_reload_pending(&self) -> bool {
512        self.reload_pending.is_some()
513    }
514
515    /// The pid the daemon reported in `Welcome`.
516    pub fn daemon_pid(&self) -> u32 {
517        self.welcome.pid
518    }
519
520    /// The reconnect budget from `SYNAPS_TUI_ATTACH_RECONNECT_SECS` (default 60).
521    pub fn reconnect_budget() -> Duration {
522        std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
523            .ok()
524            .and_then(|s| s.parse().ok())
525            .map(Duration::from_secs)
526            .unwrap_or(Duration::from_secs(60))
527    }
528
529    /// One attempt at reconnecting (Hello + Attach). On success replaces
530    /// `self` and returns the snapshot. On `Refused` / `Version` the error is
531    /// terminal (caller should stop retrying). Other errors are transient.
532    pub async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
533        let was_owner = self.input_owner == Some(self.client);
534        let generation = self.reload_pending.unwrap_or(self.welcome.generation);
535        let mut hello = self.hello.clone();
536        hello.reconnect_of = Some(ClientReconnect {
537            previous_client: self.client,
538            session_id: self.session.clone(),
539            was_owner,
540            generation,
541        });
542        let mode = if was_owner { AttachMode::Takeover } else { mode };
543        match Connected::connect(&self.path, hello).await {
544            Ok(conn) => {
545                match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
546                    Ok((t, snap)) => {
547                        *self = t;
548                        Ok(snap)
549                    }
550                    Err(e) => Err(e),
551                }
552            }
553            Err(e) => Err(e),
554        }
555    }
556
557    // ── control fast path helpers (fresh connection each) ──
558
559    pub async fn ping(path: &Path) -> Result<Pong, TransportError> {
560        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
561        let p = c.ping().await;
562        c.bye().await;
563        p
564    }
565
566    pub async fn sessions(path: &Path) -> Result<Vec<SessionMeta>, TransportError> {
567        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
568        let s = c.sessions().await;
569        c.bye().await;
570        s
571    }
572
573    pub async fn shutdown(path: &Path, force: bool) -> Result<(), TransportError> {
574        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
575        c.shutdown(force).await
576    }
577
578    pub async fn purge(path: &Path) -> Result<Pong, TransportError> {
579        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
580        let p = c.purge().await;
581        c.bye().await;
582        p
583    }
584
585    /// `Close{id, force, attached}` on a fresh control connection (see
586    /// [`Connected::close`]).
587    pub async fn close(path: &Path, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
588        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
589        let r = c.close(id, force, attached).await;
590        c.bye().await;
591        r
592    }
593
594    /// `Pin{id, on}` on a fresh control connection (see [`Connected::pin`]).
595    pub async fn pin(path: &Path, id: &SessionId, on: bool) -> Result<bool, TransportError> {
596        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
597        let r = c.pin(id, on).await;
598        c.bye().await;
599        r
600    }
601
602    /// `AuthState` on a control connection (see [`Connected::auth_state`]).
603    pub async fn auth_state(path: &Path, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
604        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
605        let reply = c.auth_state(query).await;
606        c.bye().await;
607        reply
608    }
609
610    /// `Reload{..}` on a control connection. `Ok(generation)` once the daemon
611    /// answered `Bye{Reloading{generation}}` (it is about to exec);
612    /// `Refused{ReloadRefused}` / `Error` → `Err`.
613    pub async fn reload(path: &Path, now: bool, drain_secs: Option<u64>, exe: Option<PathBuf>) -> Result<u64, TransportError> {
614        let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
615        c.writer
616            .send(ClientFrame::Reload { now, drain_secs, exe })
617            .await
618            .map_err(|_| TransportError::Closed)?;
619        // Drain + checkpoint can take a while: wait up to drain + 60 s.
620        let budget = Duration::from_secs(drain_secs.unwrap_or(30) + 60);
621        let frame = tokio::time::timeout(budget, read_frame(&mut c.reader, &mut c.buf))
622            .await
623            .map_err(|_| TransportError::Protocol("reload reply timed out".into()))??;
624        match frame {
625            Some(DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation, .. }) }) => Ok(generation),
626            Some(DaemonFrame::Bye { .. }) => Err(TransportError::Protocol("daemon said bye without a reload reason".into())),
627            Some(DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why }, .. }) => Err(TransportError::Refused(why)),
628            Some(DaemonFrame::Refused { message, .. }) | Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Refused(message)),
629            Some(other) => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
630            None => Err(TransportError::Closed),
631        }
632    }
633
634    /// Detach cleanly: `Detach` + `Bye`. The turn keeps running in the daemon.
635    pub async fn detach(&self) {
636        let _ = self
637            .writer
638            .send(ClientFrame::Cmd { session_id: self.session.clone(), cmd: SessionCommand::Detach { client: self.client } })
639            .await;
640        let _ = self.writer.send(ClientFrame::Bye).await;
641    }
642
643    fn notice(&self, text: String) -> Envelope {
644        Envelope { session_id: self.session.clone(), seq: u64::MAX, ts: chrono::Utc::now(), event: SessionEventWire::SystemNotice(text) }
645    }
646
647    /// The client's view of `api_messages` (kept current by the digest
648    /// protocol in `Full` mode; always empty in `Digest` mode).
649    pub fn messages(&self) -> &[crate::SharedMessage] {
650        &self.messages
651    }
652
653    /// Length in bytes of the last frame decoded on this connection
654    /// (the `Attached` frame right after `attach`).
655    pub fn last_frame_bytes(&self) -> usize {
656        self.buf.last_frame_bytes
657    }
658
659    pub fn history_mode(&self) -> HistoryMode {
660        self.history
661    }
662
663    /// Turn a wire envelope into the in-process one, keeping the message
664    /// mirror honest. `None` = swallowed (reserved query id, or a digest that
665    /// needs a re-query first — the `Conversation` is re-emitted once the
666    /// `Messages` result lands).
667    fn absorb(&mut self, w: WireEnvelope) -> Option<Envelope> {
668        if self.history == HistoryMode::Digest {
669            return self.absorb_digest(w);
670        }
671        match w.event {
672            WireSessionEvent::Conversation { digest } => {
673                if digest.matches(&self.messages) {
674                    let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
675                    return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
676                }
677                if digest.messages_len == 0 {
678                    self.messages.clear();
679                    let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
680                    return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
681                }
682                // Mirror drifted (compaction, abort repair, flushed events): re-fetch.
683                let first = self.pending_digest.is_none();
684                self.pending_digest = Some(digest);
685                if first {
686                    let _ = self.writer.try_send(ClientFrame::Cmd {
687                        session_id: self.session.clone(),
688                        cmd: SessionCommand::Query { id: DIGEST_RESYNC_QUERY_ID, query: SessionQuery::Messages },
689                    });
690                }
691                None
692            }
693            WireSessionEvent::QueryResult { id, value } if id == DIGEST_RESYNC_QUERY_ID => {
694                if let Ok(msgs) = serde_json::from_value::<Vec<serde_json::Value>>(value) {
695                    self.messages = msgs.into_iter().map(Arc::new).collect();
696                }
697                let digest = self.pending_digest.take()?;
698                if !digest.matches(&self.messages) {
699                    tracing::debug!("socket_transport: messages re-query still disagrees with digest; using fetched history");
700                }
701                let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
702                Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
703            }
704            WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
705            event => {
706                let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
707                match &env.event {
708                    SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(m))) => {
709                        self.messages = m.clone();
710                    }
711                    SessionEventWire::Attached { snapshot, .. } => {
712                        self.messages = snapshot.conversation.api_messages.clone();
713                    }
714                    _ => {}
715                }
716                Some(env)
717            }
718        }
719    }
720}
721
722impl SocketTransport {
723    /// `HistoryMode::Digest`: no mirror, no `matches()` re-serialisation, no
724    /// `Query{Messages}`. `Conversation` is passed through with an empty
725    /// `api_messages` (`messages_len` from the digest); a stray
726    /// `MessageHistory` is dropped (the daemon should not send one).
727    fn absorb_digest(&mut self, w: WireEnvelope) -> Option<Envelope> {
728        match w.event {
729            WireSessionEvent::Conversation { digest } => {
730                let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
731                Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
732            }
733            WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
734            event => {
735                let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
736                if matches!(
737                    env.event,
738                    SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(_)))
739                ) {
740                    return None;
741                }
742                Some(env)
743            }
744        }
745    }
746}
747
748#[async_trait::async_trait]
749impl ClientTransport for SocketTransport {
750    fn session_id(&self) -> &SessionId {
751        &self.session
752    }
753
754    fn meta(&self) -> &SessionMeta {
755        &self.meta
756    }
757
758    async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
759        match self.writer.try_send(ClientFrame::Cmd { session_id: self.session.clone(), cmd }) {
760            Ok(()) => Ok(()),
761            Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
762            Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
763        }
764    }
765
766    /// The daemon's conn stamps `from` with this connection's client id, so
767    /// on the wire `send` and `send_from_self` are the same frame.
768    async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
769        self.send(cmd).await
770    }
771
772    async fn next_event(&mut self) -> Option<Envelope> {
773        if self.ended {
774            return None;
775        }
776        loop {
777            match read_frame(&mut self.reader, &mut self.buf).await {
778                Ok(Some(DaemonFrame::Event(w))) => {
779                    let Some(env) = self.absorb(w) else { continue };
780                    match &env.event {
781                        SessionEventWire::SettingChanged(applied) => {
782                            self.view.store(Arc::new(applied.view.clone()));
783                        }
784                        SessionEventWire::Ended { .. } => self.ended = true,
785                        SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
786                        SessionEventWire::Reloading { generation, .. } => {
787                            self.reload_pending = Some(*generation);
788                        }
789                        other => self.roster.apply(other),
790                    }
791                    return Some(env);
792                }
793                Ok(Some(DaemonFrame::Error { message, .. })) => return Some(self.notice(message)),
794                Ok(Some(DaemonFrame::Bye { reason })) => {
795                    if let Some(ByeReason::Reloading { generation, .. }) = reason {
796                        self.reload_pending = Some(generation);
797                    }
798                    self.ended = true;
799                    if let Some(generation) = self.reload_pending {
800                        self.last_error = Some(TransportError::Reloading { generation });
801                    }
802                    return None;
803                }
804                Ok(None) => {
805                    self.ended = true;
806                    if let Some(generation) = self.reload_pending {
807                        self.last_error = Some(TransportError::Reloading { generation });
808                    }
809                    return None;
810                }
811                Ok(Some(other)) => {
812                    tracing::debug!("socket_transport: unexpected frame after attach: {other:?}");
813                    continue;
814                }
815                Err(e) => {
816                    tracing::debug!("socket_transport: read error: {e}");
817                    self.ended = true;
818                    return None;
819                }
820            }
821        }
822    }
823
824    fn view(&self) -> Arc<RuntimeView> {
825        Arc::clone(&self.view.load())
826    }
827
828    fn client_id(&self) -> ClientId {
829        self.client
830    }
831
832    fn mode(&self) -> AttachMode {
833        self.mode
834    }
835
836    fn input_owner(&self) -> Option<ClientId> {
837        self.input_owner
838    }
839
840    fn roster(&self) -> Roster {
841        self.roster.clone()
842    }
843
844    fn client_input(&self) -> ClientInput {
845        self.client_input
846    }
847
848    /// C3: after `Reloading`/EOF — backoff 100 ms ×2 → cap 5 s, total ≤
849    /// `SYNAPS_TUI_ATTACH_RECONNECT_SECS` (60); `Hello{reconnect_of}` →
850    /// `Attach::Existing{alias-resolved id, mode}` where `mode = Takeover`
851    /// iff this client was the input owner (§2.7: two reconnecting mirrors
852    /// cannot both take over). On success `self` IS the new connection; the
853    /// returned snapshot is a full re-mirror.
854    async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
855        let was_owner = self.input_owner == Some(self.client);
856        let generation = self.reload_pending.unwrap_or(self.welcome.generation);
857        let mut hello = self.hello.clone();
858        hello.reconnect_of = Some(ClientReconnect {
859            previous_client: self.client,
860            session_id: self.session.clone(),
861            was_owner,
862            generation,
863        });
864        let mode = if was_owner { AttachMode::Takeover } else { mode };
865        let total = std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
866            .ok()
867            .and_then(|s| s.parse().ok())
868            .map(Duration::from_secs)
869            .unwrap_or(Duration::from_secs(60));
870        let deadline = tokio::time::Instant::now() + total;
871        let mut backoff = Duration::from_millis(100);
872        let mut last;
873        loop {
874            match Connected::connect(&self.path, hello.clone()).await {
875                Ok(conn) => {
876                    match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
877                        Ok((t, snap)) => {
878                            *self = t;
879                            return Ok(snap);
880                        }
881                        Err(TransportError::Refused(m)) => return Err(TransportError::Refused(m)),
882                        Err(e) => last = e,
883                    }
884                }
885                Err(TransportError::Version { client, daemon }) => {
886                    return Err(TransportError::Version { client, daemon })
887                }
888                Err(e) => last = e,
889            }
890            if tokio::time::Instant::now() + backoff > deadline {
891                return Err(last);
892            }
893            tokio::time::sleep(backoff).await;
894            backoff = (backoff * 2).min(Duration::from_secs(5));
895        }
896    }
897
898    async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
899        // Delegate to the inherent method (same name, not trait-dispatched).
900        SocketTransport::reconnect_once(self, mode).await
901    }
902
903    fn is_reload_pending(&self) -> bool {
904        SocketTransport::is_reload_pending(self)
905    }
906
907    fn daemon_pid(&self) -> u32 {
908        SocketTransport::daemon_pid(self)
909    }
910
911    fn daemon_binary(&self) -> Option<crate::session::transport::BinaryId> {
912        Some(crate::session::transport::BinaryId::of(&self.welcome))
913    }
914
915    fn socket_path(&self) -> Option<&Path> {
916        Some(&self.path)
917    }
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::{LlmEvent, StreamEvent};
924    use tokio::net::UnixListener;
925
926    /// A hand-rolled daemon: Welcome, answer Ping, Attach → Attached, then
927    /// echo every Cmd::Submit as an Event stream.
928    async fn fake_daemon(listener: UnixListener, protocol_version: u32) {
929        let (stream, _) = listener.accept().await.unwrap();
930        let (r, mut w) = stream.into_split();
931        let mut reader = BufReader::new(r);
932        let mut line = String::new();
933        let mut seq = 0u64;
934        let sid = SessionId::from("fake-1");
935        loop {
936            line.clear();
937            if reader.read_line(&mut line).await.unwrap() == 0 {
938                break;
939            }
940            let frame: ClientFrame = decode_line(line.trim_end()).unwrap();
941            let reply = match frame {
942                ClientFrame::Hello(h) => {
943                    if h.protocol_version != protocol_version {
944                        let f = DaemonFrame::Refused {
945                            reason: RefuseReason::Version { daemon_version: protocol_version, min: protocol_version, max: protocol_version },
946                            message: "version".into(),
947                        };
948                        w.write_all(encode_line(&f).unwrap().as_bytes()).await.unwrap();
949                        break;
950                    }
951                    DaemonFrame::Welcome(Welcome {
952                        protocol_version,
953                        daemon_version: "t".into(),
954                        pid: 1,
955                        profile: None,
956                        sessions: vec![],
957                        progressive_tool_disclosure: true,
958                        generation: 1,
959                        daemon_build: String::new(),
960                    })
961                }
962                ClientFrame::Ping => DaemonFrame::Pong { pid: 1, uptime_s: 0, sessions: 0 },
963                ClientFrame::Attach(_) => DaemonFrame::Attached(AttachedWire::new(
964                    ClientId(7),
965                    AttachSnapshot {
966                        meta: crate::session::handle::echo::meta_for(&sid),
967                        view: crate::session::handle::echo::view_for(),
968                        conversation: ConversationSnapshot::default(),
969                        streaming: false,
970                        replay: vec![],
971                        pending_prompts: vec![],
972                        clients: vec![],
973                        roster: Vec::new(),
974                        client_input: ClientInput::wire_default(),
975                        input_owner: Some(ClientId(7)),
976                        display_tail: None,
977                    },
978                )),
979                ClientFrame::Cmd { cmd: SessionCommand::Submit { text, .. }, .. } => {
980                    seq += 1;
981                    DaemonFrame::Event(WireEnvelope {
982                        session_id: sid.clone(),
983                        seq,
984                        ts: chrono::Utc::now(),
985                        event: SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(text))).into(),
986                    })
987                }
988                ClientFrame::Cmd { cmd: SessionCommand::Set { id, .. }, .. } => {
989                    seq += 1;
990                    let mut view = crate::session::handle::echo::view_for();
991                    view.model = "changed".into();
992                    DaemonFrame::Event(WireEnvelope {
993                        session_id: sid.clone(),
994                        seq,
995                        ts: chrono::Utc::now(),
996                        event: SessionEventWire::SettingChanged(SettingApplied {
997                            id,
998                            setting: "model".into(),
999                            ok: true,
1000                            message: None,
1001                            view,
1002                            clamp: None,
1003                        })
1004                        .into(),
1005                    })
1006                }
1007                // Checkpoint = "the daemon is about to reload": Reloading event, then Bye{Reloading}.
1008                ClientFrame::Cmd { cmd: SessionCommand::Checkpoint { .. }, .. } => {
1009                    seq += 1;
1010                    let ev = DaemonFrame::Event(WireEnvelope {
1011                        session_id: sid.clone(),
1012                        seq,
1013                        ts: chrono::Utc::now(),
1014                        event: WireSessionEvent::Reloading { generation: 2, retry_after_ms: 500 },
1015                    });
1016                    w.write_all(encode_line(&ev).unwrap().as_bytes()).await.unwrap();
1017                    let bye = DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) };
1018                    w.write_all(encode_line(&bye).unwrap().as_bytes()).await.unwrap();
1019                    break;
1020                }
1021                // KeepWarm = ownership change notice.
1022                ClientFrame::Cmd { cmd: SessionCommand::KeepWarm { .. }, .. } => {
1023                    seq += 1;
1024                    DaemonFrame::Event(WireEnvelope {
1025                        session_id: sid.clone(),
1026                        seq,
1027                        ts: chrono::Utc::now(),
1028                        event: WireSessionEvent::InputOwnerChanged {
1029                            from: Some(ClientId(7)),
1030                            to: Some(ClientId(9)),
1031                            reason: OwnerChangeReason::Takeover,
1032                        },
1033                    })
1034                }
1035                // Steer = "the daemon's history drifted": digest for [user:text] without a MessageHistory.
1036                ClientFrame::Cmd { cmd: SessionCommand::Steer { text }, .. } => {
1037                    seq += 1;
1038                    let snap = ConversationSnapshot {
1039                        api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":text}))],
1040                        cost: 1.5,
1041                        ..Default::default()
1042                    };
1043                    DaemonFrame::Event(WireEnvelope {
1044                        session_id: sid.clone(),
1045                        seq,
1046                        ts: chrono::Utc::now(),
1047                        event: SessionEventWire::Conversation(snap).into(),
1048                    })
1049                }
1050                ClientFrame::Cmd { cmd: SessionCommand::Query { id, query: SessionQuery::Messages }, .. } => {
1051                    seq += 1;
1052                    DaemonFrame::Event(WireEnvelope {
1053                        session_id: sid.clone(),
1054                        seq,
1055                        ts: chrono::Utc::now(),
1056                        event: WireSessionEvent::QueryResult {
1057                            id,
1058                            value: serde_json::json!([{"role":"user","content":"drifted"}]),
1059                        },
1060                    })
1061                }
1062                ClientFrame::Bye => {
1063                    let _ = w.write_all(encode_line(&DaemonFrame::Bye { reason: None }).unwrap().as_bytes()).await;
1064                    break;
1065                }
1066                ClientFrame::Cmd { cmd: SessionCommand::Detach { .. }, .. } => continue,
1067                _ => DaemonFrame::Error { session_id: None, message: "nope".into() },
1068            };
1069            let bytes = encode_line(&reply).unwrap();
1070            // `split:` Submit text: the frame arrives in two writes 50 ms
1071            // apart (a frame split across reads, as a large one can be).
1072            if bytes.contains("split:") {
1073                let (a, b) = bytes.as_bytes().split_at(bytes.len() / 2);
1074                w.write_all(a).await.unwrap();
1075                w.flush().await.unwrap();
1076                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1077                w.write_all(b).await.unwrap();
1078                continue;
1079            }
1080            w.write_all(bytes.as_bytes()).await.unwrap();
1081        }
1082    }
1083
1084    fn sock() -> (tempfile::TempDir, std::path::PathBuf) {
1085        let d = tempfile::tempdir().unwrap();
1086        let p = d.path().join("d.sock");
1087        (d, p)
1088    }
1089
1090    /// A daemon built before `AuthState` decodes the frame into a
1091    /// `ClientFrame` without that variant, fails, and answers
1092    /// `format!("malformed frame: {e}")` (daemon/conn.rs `Read::Bad`). That
1093    /// reply, and no other error, means "the daemon predates auth state".
1094    #[test]
1095    fn predates_auth_state_matches_an_old_daemons_decode_error_and_nothing_else() {
1096        #[derive(serde::Deserialize)]
1097        #[serde(tag = "type", rename_all = "snake_case")]
1098        #[allow(dead_code, clippy::large_enum_variant)]
1099        enum OldClientFrame {
1100            Hello(Hello),
1101            Ping,
1102            Sessions,
1103            Shutdown {
1104                #[serde(default)]
1105                force: bool,
1106            },
1107            Purge,
1108            Bye,
1109        }
1110        for query in [
1111            agent_core::auth::AuthStateQuery::default(),
1112            agent_core::auth::AuthStateQuery { provider: Some("anthropic".into()), account: Some("b".into()), clear: true },
1113        ] {
1114            let line = encode_line(&ClientFrame::AuthState(query)).unwrap();
1115            let err = decode_line::<OldClientFrame>(line.trim_end()).err().expect("an old daemon cannot decode it");
1116            let reply = format!("malformed frame: {err}");
1117            assert!(predates_auth_state(&reply), "{reply}");
1118        }
1119        for other in [
1120            "auth state: unknown account `zzz`",
1121            "malformed frame: expected value at line 1 column 1",
1122            "malformed frame: unknown variant `auth_status`, expected one of `hello`, `ping`",
1123            "malformed frame: missing field `clear`",
1124            "control frames are not accepted on an attached connection",
1125            "duplicate hello",
1126            "",
1127        ] {
1128            assert!(!predates_auth_state(other), "{other:?}");
1129        }
1130    }
1131
1132    /// The TUI polls `next_event()` inside `tokio::select!` next to a 16 ms
1133    /// tick, so the future is dropped whenever the tick wins. A frame split
1134    /// across reads must survive that cancellation.
1135    #[tokio::test]
1136    async fn next_event_survives_cancellation_mid_frame() {
1137        let (_d, path) = sock();
1138        let l = UnixListener::bind(&path).unwrap();
1139        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1140        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1141        let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1142            .await
1143            .unwrap();
1144        t.send(SessionCommand::Submit { text: "split:hello".into(), attachments: vec![] }).await.unwrap();
1145        // First half is in; the tick wins and drops the read.
1146        tokio::select! {
1147            e = t.next_event() => panic!("frame cannot be complete yet: {e:?}"),
1148            _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => {}
1149        }
1150        let e = tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
1151            .await
1152            .expect("no event");
1153        match e.map(|e| e.event) {
1154            Some(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s)))) => assert_eq!(s, "split:hello"),
1155            other => panic!("frame lost to cancellation: {other:?} (last_error={:?})", t.last_error()),
1156        }
1157        t.detach().await;
1158        let _ = srv.await;
1159    }
1160
1161    #[tokio::test]
1162    async fn socket_transport_handshake_attach_pump() {
1163        let (_d, path) = sock();
1164        let l = UnixListener::bind(&path).unwrap();
1165        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1166
1167        let mut conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1168        assert_eq!(conn.welcome.protocol_version, PROTOCOL_VERSION);
1169        assert_eq!(conn.ping().await.unwrap().pid, 1);
1170        let (mut t, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1171            .await
1172            .unwrap();
1173        assert_eq!(t.client_id(), ClientId(7));
1174        assert_eq!(t.mode(), AttachMode::Mirror);
1175        assert_eq!(t.input_owner(), Some(ClientId(7)));
1176        assert_eq!(snap.meta.id.as_str(), "fake-1");
1177        assert_eq!(t.session_id().as_str(), "fake-1");
1178        assert_eq!(t.view().model, "echo");
1179
1180        t.send(SessionCommand::Submit { text: "hello".into(), attachments: vec![] }).await.unwrap();
1181        let e = t.next_event().await.unwrap();
1182        match e.event {
1183            SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s))) => assert_eq!(s, "hello"),
1184            other => panic!("{other:?}"),
1185        }
1186        t.send_from_self(SessionCommand::Set { id: 11, setting: SessionSetting::Model { model: "changed".into() } })
1187            .await
1188            .unwrap();
1189        let e = t.next_event().await.unwrap();
1190        assert!(matches!(e.event, SessionEventWire::SettingChanged(SettingApplied { id: 11, .. })));
1191        assert_eq!(t.view().model, "changed");
1192        t.send(SessionCommand::KeepWarm { on: true }).await.unwrap();
1193        let e = t.next_event().await.unwrap();
1194        assert!(matches!(e.event, SessionEventWire::InputOwnerChanged { .. }));
1195        assert_eq!(t.input_owner(), Some(ClientId(9)));
1196
1197        // Error → SystemNotice; Bye → None.
1198        t.send(SessionCommand::Save).await.unwrap();
1199        assert!(matches!(t.next_event().await.unwrap().event, SessionEventWire::SystemNotice(_)));
1200        t.detach().await;
1201        assert!(t.next_event().await.is_none());
1202        assert!(t.next_event().await.is_none());
1203        srv.await.unwrap();
1204    }
1205
1206    #[tokio::test]
1207    async fn conversation_digest_miss_requeries_messages() {
1208        let (_d, path) = sock();
1209        let l = UnixListener::bind(&path).unwrap();
1210        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1211        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1212        let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1213            .await
1214            .unwrap();
1215        assert!(t.messages().is_empty());
1216        t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1217        // The digest misses the (empty) mirror → transport re-queries → one
1218        // Conversation with the fetched history and the digest's cost.
1219        let e = t.next_event().await.unwrap();
1220        match e.event {
1221            SessionEventWire::Conversation(c) => {
1222                assert_eq!(c.api_messages.len(), 1);
1223                assert_eq!(c.api_messages[0]["content"], "drifted");
1224                assert_eq!(c.cost, 1.5);
1225            }
1226            other => panic!("{other:?}"),
1227        }
1228        assert_eq!(t.messages().len(), 1);
1229        // Same digest again now matches the mirror: no re-query, served locally.
1230        t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1231        let e = t.next_event().await.unwrap();
1232        assert!(matches!(e.event, SessionEventWire::Conversation(ref c) if c.api_messages.len() == 1));
1233        t.detach().await;
1234        assert!(t.next_event().await.is_none());
1235        srv.await.unwrap();
1236    }
1237
1238    #[tokio::test]
1239    async fn reloading_then_bye_sets_last_error_and_reconnect_gives_up_on_a_dead_socket() {
1240        let (_d, path) = sock();
1241        let l = UnixListener::bind(&path).unwrap();
1242        let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1243        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1244        let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Observe })
1245            .await
1246            .unwrap();
1247        assert_eq!(t.mode(), AttachMode::Observe);
1248        assert!(t.last_error().is_none());
1249        t.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload }).await.unwrap();
1250        let e = t.next_event().await.unwrap();
1251        assert!(matches!(e.event, SessionEventWire::Reloading { generation: 2, .. }));
1252        assert!(t.next_event().await.is_none());
1253        assert!(matches!(t.last_error(), Some(TransportError::Reloading { generation: 2 })));
1254        srv.await.unwrap();
1255        // Nobody listens any more: the backoff loop gives up at the budget.
1256        std::env::set_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS", "1");
1257        let r = t.reconnect(AttachMode::Observe).await;
1258        std::env::remove_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS");
1259        assert!(r.is_err());
1260    }
1261
1262    #[tokio::test]
1263    async fn attach_error_frame_is_refused() {
1264        let (_d, path) = sock();
1265        let l = UnixListener::bind(&path).unwrap();
1266        // A daemon that welcomes, then answers the Attach with an Error frame.
1267        let srv = tokio::spawn(async move {
1268            let (stream, _) = l.accept().await.unwrap();
1269            let (r, mut w) = stream.into_split();
1270            let mut reader = BufReader::new(r);
1271            let mut line = String::new();
1272            reader.read_line(&mut line).await.unwrap();
1273            let welcome = DaemonFrame::Welcome(Welcome {
1274                protocol_version: PROTOCOL_VERSION,
1275                daemon_version: "t".into(),
1276                pid: 1,
1277                profile: None,
1278                sessions: vec![],
1279                progressive_tool_disclosure: true,
1280                generation: 1,
1281                daemon_build: String::new(),
1282            });
1283            w.write_all(encode_line(&welcome).unwrap().as_bytes()).await.unwrap();
1284            line.clear();
1285            reader.read_line(&mut line).await.unwrap();
1286            let err = DaemonFrame::Error { session_id: None, message: "unknown session".into() };
1287            w.write_all(encode_line(&err).unwrap().as_bytes()).await.unwrap();
1288        });
1289        let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1290        let err = SocketTransport::attach(conn, Attach::Existing { session_id: "nope".into(), mode: AttachMode::Mirror })
1291            .await
1292            .err()
1293            .unwrap();
1294        assert!(matches!(err, TransportError::Refused(ref m) if m == "unknown session"), "{err}");
1295        srv.await.unwrap();
1296    }
1297
1298    #[tokio::test]
1299    async fn socket_transport_refuses_version_mismatch() {
1300        let (_d, path) = sock();
1301        let l = UnixListener::bind(&path).unwrap();
1302        let srv = tokio::spawn(fake_daemon(l, 99));
1303        let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1304        assert!(matches!(err, TransportError::Version { client: PROTOCOL_VERSION, daemon: 99 }), "{err}");
1305        srv.await.unwrap();
1306    }
1307
1308    /// An older daemon answers `Close` with serde's unknown-variant error;
1309    /// the client reports "too old" (exit 2), not a generic failure.
1310    #[test]
1311    fn close_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1312        let old = DaemonFrame::Error {
1313            session_id: None,
1314            message: "malformed frame: unknown variant `close`, expected one of `hello`, `ping`".into(),
1315        };
1316        let err = close_reply(Some(old), "0.9.0").unwrap_err();
1317        assert!(matches!(err, TransportError::DaemonTooOld { ref daemon_version, .. } if daemon_version == "0.9.0"), "{err}");
1318        assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1319
1320        let closed = DaemonFrame::Closed { session_id: "s".into(), text: "closing session s".into() };
1321        assert_eq!(close_reply(Some(closed), "1").unwrap(), "closing session s");
1322        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "not closed: a turn is running".into() };
1323        assert!(matches!(close_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("turn")));
1324        let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1325        assert!(matches!(close_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1326        let other = DaemonFrame::Error { session_id: None, message: "close: no reply".into() };
1327        assert!(matches!(close_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1328        assert!(matches!(close_reply(None, "1"), Err(TransportError::Closed)));
1329    }
1330
1331    #[test]
1332    fn pin_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1333        let old = DaemonFrame::Error {
1334            session_id: None,
1335            message: "malformed frame: unknown variant `pin`, expected one of `hello`, `ping`".into(),
1336        };
1337        let err = pin_reply(Some(old), "0.9.0").unwrap_err();
1338        assert!(
1339            matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon pin" } if daemon_version == "0.9.0"),
1340            "{err}"
1341        );
1342        assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1343
1344        assert!(pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: true }), "1").unwrap());
1345        assert!(!pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: false }), "1").unwrap());
1346        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1347        assert!(matches!(pin_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1348        let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1349        assert!(matches!(pin_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1350        let other = DaemonFrame::Error { session_id: None, message: "pin: timed out".into() };
1351        assert!(matches!(pin_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1352        let closed = DaemonFrame::Closed { session_id: "s".into(), text: "x".into() };
1353        assert!(matches!(pin_reply(Some(closed), "1"), Err(TransportError::Protocol(_))));
1354        assert!(matches!(pin_reply(None, "1"), Err(TransportError::Closed)));
1355    }
1356
1357    #[test]
1358    fn reload_extension_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1359        let old = DaemonFrame::Error {
1360            session_id: None,
1361            message: "malformed frame: unknown variant `reload_extension`, expected one of `hello`, `ping`".into(),
1362        };
1363        let err = reload_extension_reply(Some(old), "0.9.0").unwrap_err();
1364        assert!(
1365            matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon reload-extension" } if daemon_version == "0.9.0"),
1366            "{err}"
1367        );
1368
1369        let report = ExtensionReloadReport {
1370            extension: "axel".into(),
1371            was_loaded: true,
1372            deferred: false,
1373            tools: vec!["axel:axel_search".into()],
1374            removed_tools: vec![],
1375            plugin_dir: std::path::PathBuf::from("/p/axel"),
1376        };
1377        let ok = DaemonFrame::ExtensionReloaded { report: report.clone() };
1378        assert_eq!(reload_extension_reply(Some(ok), "1").unwrap(), Ok(report));
1379        let failed = DaemonFrame::ExtensionReloadFailed { extension: "axel".into(), message: "spawn failed".into(), unloaded: true };
1380        assert_eq!(
1381            reload_extension_reply(Some(failed), "1").unwrap(),
1382            Err(ExtensionReloadFailure { message: "spawn failed".into(), unloaded: true })
1383        );
1384        let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1385        assert!(matches!(reload_extension_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1386        // A plain `Error` (not the unknown-variant one) stays a protocol error.
1387        let other = DaemonFrame::Error { session_id: None, message: "frame too large".into() };
1388        assert!(matches!(reload_extension_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1389        let pinned = DaemonFrame::Pinned { session_id: "s".into(), on: true };
1390        assert!(matches!(reload_extension_reply(Some(pinned), "1"), Err(TransportError::Protocol(_))));
1391        assert!(matches!(reload_extension_reply(None, "1"), Err(TransportError::Closed)));
1392    }
1393
1394    #[tokio::test]
1395    async fn socket_transport_connect_refused_when_nobody_listens() {
1396        let (_d, path) = sock();
1397        let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1398        assert!(matches!(err, TransportError::Io(_)));
1399    }
1400}